diff --git a/README.md b/README.md index db0851f5..33b94c24 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ Pass these options to the HoneycombWebSDK: | enabled | optional | boolean | `true` | Where or not to enable this auto instrumentation. | | lcp| optional| VitalOpts | `undefined` | Pass-through config options for web-vitals. See [ReportOpts](https://github.com/GoogleChrome/web-vitals?tab=readme-ov-file#reportopts). | lcp.applyCustomAttributes| optional| function | `undefined` | A function for adding custom attributes to core web vitals spans. +| lcp.dataAttributes| optional| `string[]` | `undefined` | An array of attribute names to filter reported as `lcp.element.data.someAttr`
  • `undefined` will send all `data-*` attribute-value pairs.
  • `[]` will send none
  • `['myAttr']` will send the value of `data-my-attr` or `''` if it's not supplied.

    Note: An attribute that's defined, but that has no specified value such as `

    ` will be sent as `{`lcp.element.data.myAttr`: '' }` which is inline with the [dataset API]( https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset). | cls| optional| VitalOpts | `undefined` | Pass-through config options for web-vitals. See [ReportOpts](https://github.com/GoogleChrome/web-vitals?tab=readme-ov-file#reportopts). | cls.applyCustomAttributes| optional| function | `undefined` | A function for adding custom attributes to core web vitals spans. | inp| optional| VitalOptsWithTimings | `undefined` | Pass-through config options for web-vitals. See [ReportOpts](https://github.com/GoogleChrome/web-vitals?tab=readme-ov-file#reportopts). diff --git a/packages/honeycomb-opentelemetry-web/examples/hello-world-web/index.html b/packages/honeycomb-opentelemetry-web/examples/hello-world-web/index.html index 2fe85a28..bb25f941 100644 --- a/packages/honeycomb-opentelemetry-web/examples/hello-world-web/index.html +++ b/packages/honeycomb-opentelemetry-web/examples/hello-world-web/index.html @@ -1,23 +1,23 @@ - + - - - - Honeycomb OpenTelemetry Web Distro - - + + + + Honeycomb OpenTelemetry Web Distro + + -
    -
    -

    👋 Hello World

    -
    +
    +
    +

    👋 Hello World

    +
    - -
    - -
    -
    - - - + +
    + +
    +
    + + + diff --git a/packages/honeycomb-opentelemetry-web/examples/hello-world-web/index.js b/packages/honeycomb-opentelemetry-web/examples/hello-world-web/index.js index b2395b5b..fb405c0f 100644 --- a/packages/honeycomb-opentelemetry-web/examples/hello-world-web/index.js +++ b/packages/honeycomb-opentelemetry-web/examples/hello-world-web/index.js @@ -26,6 +26,9 @@ const main = () => { contextManager: new ZoneContextManager(), webVitalsInstrumentationConfig: { vitalsToTrack: ['CLS', 'FCP', 'FID', 'INP', 'LCP', 'TTFB'], + lcp: { + dataAttributes: ['hello', 'barBiz'], + }, }, }); sdk.start(); diff --git a/packages/honeycomb-opentelemetry-web/examples/hello-world-web/package-lock.json b/packages/honeycomb-opentelemetry-web/examples/hello-world-web/package-lock.json index 81a2988b..fc1b0c27 100644 --- a/packages/honeycomb-opentelemetry-web/examples/hello-world-web/package-lock.json +++ b/packages/honeycomb-opentelemetry-web/examples/hello-world-web/package-lock.json @@ -22,11 +22,12 @@ }, "../..": { "name": "@honeycombio/opentelemetry-web", - "version": "0.6.0", + "version": "0.7.0", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.24.7", "@opentelemetry/api": "~1.9.0", + "@opentelemetry/auto-instrumentations-web": "^0.41.0", "@opentelemetry/core": "~1.25.1", "@opentelemetry/exporter-trace-otlp-http": "~0.52.1", "@opentelemetry/instrumentation": "~0.52.1", diff --git a/packages/honeycomb-opentelemetry-web/src/web-vitals-autoinstrumentation.ts b/packages/honeycomb-opentelemetry-web/src/web-vitals-autoinstrumentation.ts index dd0274d1..c2bec534 100644 --- a/packages/honeycomb-opentelemetry-web/src/web-vitals-autoinstrumentation.ts +++ b/packages/honeycomb-opentelemetry-web/src/web-vitals-autoinstrumentation.ts @@ -88,11 +88,19 @@ interface VitalOpts extends ReportOpts { applyCustomAttributes?: ApplyCustomAttributesFn; } -interface VitalOptsWithTimings extends VitalOpts { +interface LcpVitalOpts extends VitalOpts { + /** + * Will filter the values of these data attributes if provided, otherwise will send all data-* attributes an LCP entry + * An empty allow list, such as { dataAttributes: [] } will disable sending data-* attributes + */ + dataAttributes?: string[]; +} + +interface InpVitalOpts extends VitalOpts { /** * if this is true it will create spans from the PerformanceLongAnimationFrameTiming frames */ - includeTimingsAsSpans: boolean; + includeTimingsAsSpans?: boolean; } // To avoid importing InstrumentationAbstract from: @@ -210,13 +218,13 @@ export interface WebVitalsInstrumentationConfig extends InstrumentationConfig { vitalsToTrack?: Array; /** Config specific to LCP (Largest Contentful Paint) */ - lcp?: VitalOpts; + lcp?: LcpVitalOpts; /** Config specific to CLS (Cumulative Layout Shift) */ cls?: VitalOpts; /** Config specific to INP (Interaction to Next Paint) */ - inp?: VitalOptsWithTimings; + inp?: InpVitalOpts; /** Config specific to FID (First Input Delay) */ fid?: VitalOpts; @@ -235,9 +243,9 @@ export interface WebVitalsInstrumentationConfig extends InstrumentationConfig { */ export class WebVitalsInstrumentation extends InstrumentationAbstract { readonly vitalsToTrack: Array; - readonly lcpOpts?: VitalOpts; + readonly lcpOpts?: LcpVitalOpts; readonly clsOpts?: VitalOpts; - readonly inpOpts?: VitalOptsWithTimings; + readonly inpOpts?: InpVitalOpts; readonly fidOpts?: VitalOpts; readonly fcpOpts?: VitalOpts; readonly ttfbOpts?: VitalOpts; @@ -280,41 +288,37 @@ export class WebVitalsInstrumentation extends InstrumentationAbstract { private _setupWebVitalsCallbacks() { if (this.vitalsToTrack.includes('CLS')) { onCLS((vital) => { - this.onReportCLS(vital, this.clsOpts?.applyCustomAttributes); + this.onReportCLS(vital, this.clsOpts); }, this.clsOpts); } if (this.vitalsToTrack.includes('LCP')) { onLCP((vital) => { - this.onReportLCP(vital, this.lcpOpts?.applyCustomAttributes); + this.onReportLCP(vital, this.lcpOpts); }, this.lcpOpts); } if (this.vitalsToTrack.includes('INP')) { onINP((vital) => { - this.onReportINP( - vital, - this.inpOpts?.applyCustomAttributes, - this.inpOpts?.includeTimingsAsSpans, - ); + this.onReportINP(vital, this.inpOpts); }, this.inpOpts); } if (this.vitalsToTrack.includes('FID')) { onFID((vital) => { - this.onReportFID(vital, this.fidOpts?.applyCustomAttributes); + this.onReportFID(vital, this.fidOpts); }, this.fidOpts); } if (this.vitalsToTrack.includes('TTFB')) { onTTFB((vital) => { - this.onReportTTFB(vital, this.ttfbOpts?.applyCustomAttributes); + this.onReportTTFB(vital, this.ttfbOpts); }, this.ttfbOpts); } if (this.vitalsToTrack.includes('FCP')) { onFCP((vital) => { - this.onReportFCP(vital, this.fcpOpts?.applyCustomAttributes); + this.onReportFCP(vital, this.fcpOpts); }, this.fcpOpts); } } @@ -417,10 +421,8 @@ export class WebVitalsInstrumentation extends InstrumentationAbstract { }); } - onReportCLS = ( - cls: CLSMetricWithAttribution, - applyCustomAttributes?: ApplyCustomAttributesFn, - ) => { + onReportCLS = (cls: CLSMetricWithAttribution, clsOpts: VitalOpts = {}) => { + const { applyCustomAttributes } = clsOpts; if (!this.isEnabled()) return; const { name, attribution } = cls; @@ -451,10 +453,8 @@ export class WebVitalsInstrumentation extends InstrumentationAbstract { span.end(); }; - onReportLCP = ( - lcp: LCPMetricWithAttribution, - applyCustomAttributes?: ApplyCustomAttributesFn, - ) => { + onReportLCP = (lcp: LCPMetricWithAttribution, lcpOpts: LcpVitalOpts = {}) => { + const { applyCustomAttributes, dataAttributes } = lcpOpts; if (!this.isEnabled()) return; const { name, attribution } = lcp; @@ -465,6 +465,7 @@ export class WebVitalsInstrumentation extends InstrumentationAbstract { resourceLoadDelay, resourceLoadDuration, elementRenderDelay, + lcpEntry, }: LCPAttribution = attribution; const attrPrefix = this.getAttrPrefix(name); @@ -481,6 +482,36 @@ export class WebVitalsInstrumentation extends InstrumentationAbstract { [`${attrPrefix}.resource_load_time`]: resourceLoadDuration, }); + const el: HTMLElement = lcpEntry?.element as HTMLElement; + if (el.dataset) { + for (const attrName in el.dataset) { + const attrValue = el.dataset[attrName]; + if ( + // Value exists (including the empty string AND either + attrValue !== undefined && + // dataAttributes is undefined (i.e. send all values as span attributes) OR + (dataAttributes === undefined || + // dataAttributes is specified AND attrName is in dataAttributes (i.e attribute name is in the supplied allowList) + (dataAttributes && attrName in dataAttributes)) + ) { + span.setAttribute( + `${attrPrefix}.element.data.${attrName}`, + attrValue, + ); + } + } + } + if (dataAttributes) + dataAttributes?.forEach((attrName) => { + const attrValue = el.dataset[attrName]; + if (attrValue !== undefined) { + span.setAttribute( + `${attrPrefix}.element.data.${attrName}`, + attrValue, + ); + } + }); + if (applyCustomAttributes) { applyCustomAttributes(lcp, span); } @@ -490,9 +521,9 @@ export class WebVitalsInstrumentation extends InstrumentationAbstract { onReportINP = ( inp: INPMetricWithAttribution, - applyCustomAttributes?: ApplyCustomAttributesFn, - includeTimingsAsSpans = false, + inpOpts: InpVitalOpts = { includeTimingsAsSpans: false }, ) => { + const { applyCustomAttributes, includeTimingsAsSpans } = inpOpts; if (!this.isEnabled()) return; const { name, attribution } = inp; @@ -550,10 +581,8 @@ export class WebVitalsInstrumentation extends InstrumentationAbstract { ); }; - onReportFCP = ( - fcp: FCPMetricWithAttribution, - applyCustomAttributes?: ApplyCustomAttributesFn, - ) => { + onReportFCP = (fcp: FCPMetricWithAttribution, fcpOpts: VitalOpts = {}) => { + const { applyCustomAttributes } = fcpOpts; if (!this.isEnabled()) return; const { name, attribution } = fcp; @@ -580,10 +609,8 @@ export class WebVitalsInstrumentation extends InstrumentationAbstract { /** * @deprecated this will be removed in the next major version, use INP instead. */ - onReportFID = ( - fid: FIDMetricWithAttribution, - applyCustomAttributes?: ApplyCustomAttributesFn, - ) => { + onReportFID = (fid: FIDMetricWithAttribution, fidOpts: VitalOpts = {}) => { + const { applyCustomAttributes } = fidOpts; if (!this.isEnabled()) return; const { name, attribution } = fid; @@ -608,8 +635,9 @@ export class WebVitalsInstrumentation extends InstrumentationAbstract { onReportTTFB = ( ttfb: TTFBMetricWithAttribution, - applyCustomAttributes?: ApplyCustomAttributesFn, + ttfbOpts: VitalOpts = {}, ) => { + const { applyCustomAttributes } = ttfbOpts; if (!this.isEnabled()) return; const { name, attribution } = ttfb; diff --git a/packages/honeycomb-opentelemetry-web/test/web-vitals-instrumentation.test.ts b/packages/honeycomb-opentelemetry-web/test/web-vitals-instrumentation.test.ts index 35529bf3..932c9b00 100644 --- a/packages/honeycomb-opentelemetry-web/test/web-vitals-instrumentation.test.ts +++ b/packages/honeycomb-opentelemetry-web/test/web-vitals-instrumentation.test.ts @@ -56,6 +56,9 @@ const CLSAttr = { 'cls.entries': '', 'cls.my_custom_attr': 'custom_attr', }; +const div = document.createElement('div'); +div.innerHTML = `
    👋 Hello World
    `; +const lcpElement = div.firstElementChild; const LCP: LCPMetricWithAttribution = { name: 'LCP', @@ -72,6 +75,19 @@ const LCP: LCPMetricWithAttribution = { resourceLoadDuration: 20, elementRenderDelay: 20, resourceLoadDelay: 100, + lcpEntry: { + duration: 0, + element: lcpElement, + entryType: 'largest-contentful-paint', + id: '', + loadTime: 0, + name: '', + renderTime: 74.09999999403954, + size: 4382, + startTime: 74.09999999403954, + url: '', + toJSON: () => '', + }, }, }; @@ -303,11 +319,13 @@ describe('Web Vitals Instrumentation Tests', () => { describe('CLS', () => { it('should create a span when enabled', () => { const webVitalsInstr = new WebVitalsInstrumentation(); - webVitalsInstr.onReportCLS(CLS, (cls, span) => { - span.setAttributes({ - 'cls.entries': cls.entries.toString(), - 'cls.my_custom_attr': 'custom_attr', - }); + webVitalsInstr.onReportCLS(CLS, { + applyCustomAttributes: (cls, span) => { + span.setAttributes({ + 'cls.entries': cls.entries.toString(), + 'cls.my_custom_attr': 'custom_attr', + }); + }, }); const span = exporter.getFinishedSpans()[0]; @@ -323,11 +341,11 @@ describe('Web Vitals Instrumentation Tests', () => { vitalsToTrack: ['CLS'], }); instr.disable(); - instr.onReportCLS(CLS, () => {}); + instr.onReportCLS(CLS, { applyCustomAttributes: () => {} }); expect(exporter.getFinishedSpans().length).toEqual(0); instr.enable(); - instr.onReportCLS(CLS, () => {}); + instr.onReportCLS(CLS, { applyCustomAttributes: () => {} }); expect(exporter.getFinishedSpans().length).toEqual(1); expect(exporter.getFinishedSpans()[0].name).toEqual('CLS'); @@ -337,11 +355,14 @@ describe('Web Vitals Instrumentation Tests', () => { describe('LCP', () => { it('should create a span when enabled', () => { const webVitalsInstr = new WebVitalsInstrumentation(); - webVitalsInstr.onReportLCP(LCP, (lcp, span) => { - span.setAttributes({ - 'lcp.entries': lcp.entries.toString(), - 'lcp.my_custom_attr': 'custom_attr', - }); + webVitalsInstr.onReportLCP(LCP, { + dataAttributes: [], + applyCustomAttributes: (lcp, span) => { + span.setAttributes({ + 'lcp.entries': lcp.entries.toString(), + 'lcp.my_custom_attr': 'custom_attr', + }); + }, }); const span = exporter.getFinishedSpans()[0]; @@ -357,25 +378,84 @@ describe('Web Vitals Instrumentation Tests', () => { vitalsToTrack: ['LCP'], }); instr.disable(); - instr.onReportLCP(LCP, () => {}); + instr.onReportLCP(LCP, { applyCustomAttributes: () => {} }); expect(exporter.getFinishedSpans().length).toEqual(0); instr.enable(); - instr.onReportLCP(LCP, () => {}); + instr.onReportLCP(LCP, { applyCustomAttributes: () => {} }); expect(exporter.getFinishedSpans().length).toEqual(1); expect(exporter.getFinishedSpans()[0].name).toEqual('LCP'); }); + + it('should include add data-* attributes as span attributes when dataAttributes is undefined', () => { + const instr = new WebVitalsInstrumentation({ + vitalsToTrack: ['LCP'], + }); + instr.enable(); + instr.onReportLCP(LCP, { + applyCustomAttributes: () => {}, + dataAttributes: undefined, + }); + expect(exporter.getFinishedSpans().length).toEqual(1); + const span = exporter.getFinishedSpans()[0]; + expect(span.attributes).toMatchObject({ + 'lcp.element.data.answer': '42', + 'lcp.element.data.famousCats': 'Mr. Mistoffelees', + 'lcp.element.data.hasCats': '', + }); + }); + it('should not include any data-* attributes when dataAttributes is []', () => { + const instr = new WebVitalsInstrumentation({ + vitalsToTrack: ['LCP'], + }); + instr.enable(); + instr.onReportLCP(LCP, { + applyCustomAttributes: () => {}, + dataAttributes: [], + }); + expect(exporter.getFinishedSpans().length).toEqual(1); + const span = exporter.getFinishedSpans()[0]; + expect(span.attributes).not.toMatchObject({ + 'lcp.element.data.answer': '42', + 'lcp.element.data.famousCats': 'Mr. Mistoffelees', + 'lcp.element.data.hasCats': '', + }); + }); + it('should only include any data-* attributes that match dataAttributes array', () => { + const instr = new WebVitalsInstrumentation({ + vitalsToTrack: ['LCP'], + }); + instr.enable(); + instr.onReportLCP(LCP, { + applyCustomAttributes: () => {}, + dataAttributes: ['answer'], + }); + expect(exporter.getFinishedSpans().length).toEqual(1); + const span = exporter.getFinishedSpans()[0]; + expect(span.attributes['lcp.element.data.answer']).toEqual('42'); + expect(span.attributes['lcp.element.famousCats']).toBeUndefined(); + expect(span.attributes['lcp.element.hasCats']).toBeUndefined(); + expect(span.attributes).toMatchObject({ + 'lcp.element.data.answer': '42', + }); + expect(span.attributes).not.toMatchObject({ + 'lcp.element.data.famousCats': 'Mr. Mistoffelees', + 'lcp.element.data.hasCats': '', + }); + }); }); describe('INP', () => { it('should create a span when enabled', () => { const webVitalsInstr = new WebVitalsInstrumentation(); - webVitalsInstr.onReportINP(INP, (inp, span) => { - span.setAttributes({ - 'inp.entries': inp.entries.toString(), - 'inp.my_custom_attr': 'custom_attr', - }); + webVitalsInstr.onReportINP(INP, { + applyCustomAttributes: (inp, span) => { + span.setAttributes({ + 'inp.entries': inp.entries.toString(), + 'inp.my_custom_attr': 'custom_attr', + }); + }, }); const span = exporter.getFinishedSpans()[0]; @@ -388,20 +468,18 @@ describe('Web Vitals Instrumentation Tests', () => { it('should create a include timings when enabled', () => { const webVitalsInstr = new WebVitalsInstrumentation(); - webVitalsInstr.onReportINP( - INPWithTimings, - (inp, span) => { + webVitalsInstr.onReportINP(INPWithTimings, { + applyCustomAttributes: (inp, span) => { span.setAttributes({ 'inp.entries': inp.entries.toString(), 'inp.my_custom_attr': 'custom_attr', }); }, - true, - ); + includeTimingsAsSpans: true, + }); const [scriptTimingSpan, timingSpan, inpSpan] = exporter.getFinishedSpans(); - console.log({ timingSpan }); expect(inpSpan.name).toBe('INP'); expect(inpSpan.instrumentationLibrary.name).toBe( '@honeycombio/instrumentation-web-vitals', @@ -433,11 +511,11 @@ describe('Web Vitals Instrumentation Tests', () => { vitalsToTrack: ['INP'], }); instr.disable(); - instr.onReportINP(INP, () => {}); + instr.onReportINP(INP, { applyCustomAttributes: () => {} }); expect(exporter.getFinishedSpans().length).toEqual(0); instr.enable(); - instr.onReportINP(INP, () => {}); + instr.onReportINP(INP, { applyCustomAttributes: () => {} }); expect(exporter.getFinishedSpans().length).toEqual(1); expect(exporter.getFinishedSpans()[0].name).toEqual('INP'); @@ -447,11 +525,13 @@ describe('Web Vitals Instrumentation Tests', () => { describe('FCP', () => { it('should create a span when enabled', () => { const webVitalsInstr = new WebVitalsInstrumentation(); - webVitalsInstr.onReportFCP(FCP, (fcp, span) => { - span.setAttributes({ - 'fcp.entries': fcp.entries.toString(), - 'fcp.my_custom_attr': 'custom_attr', - }); + webVitalsInstr.onReportFCP(FCP, { + applyCustomAttributes: (fcp, span) => { + span.setAttributes({ + 'fcp.entries': fcp.entries.toString(), + 'fcp.my_custom_attr': 'custom_attr', + }); + }, }); const span = exporter.getFinishedSpans()[0]; expect(span.name).toBe('FCP'); @@ -466,11 +546,11 @@ describe('Web Vitals Instrumentation Tests', () => { vitalsToTrack: ['FCP'], }); instr.disable(); - instr.onReportFCP(FCP, () => {}); + instr.onReportFCP(FCP, { applyCustomAttributes: () => {} }); expect(exporter.getFinishedSpans().length).toEqual(0); instr.enable(); - instr.onReportFCP(FCP, () => {}); + instr.onReportFCP(FCP, { applyCustomAttributes: () => {} }); expect(exporter.getFinishedSpans().length).toEqual(1); expect(exporter.getFinishedSpans()[0].name).toEqual('FCP'); @@ -480,11 +560,13 @@ describe('Web Vitals Instrumentation Tests', () => { describe('TTFB', () => { it('should create a span when enabled', () => { const webVitalsInstr = new WebVitalsInstrumentation(); - webVitalsInstr.onReportTTFB(TTFB, (ttfb, span) => { - span.setAttributes({ - 'ttfb.entries': ttfb.entries.toString(), - 'ttfb.my_custom_attr': 'custom_attr', - }); + webVitalsInstr.onReportTTFB(TTFB, { + applyCustomAttributes: (ttfb, span) => { + span.setAttributes({ + 'ttfb.entries': ttfb.entries.toString(), + 'ttfb.my_custom_attr': 'custom_attr', + }); + }, }); const span = exporter.getFinishedSpans()[0]; @@ -500,11 +582,11 @@ describe('Web Vitals Instrumentation Tests', () => { vitalsToTrack: ['TTFB'], }); instr.disable(); - instr.onReportTTFB(TTFB, () => {}); + instr.onReportTTFB(TTFB, { applyCustomAttributes: () => {} }); expect(exporter.getFinishedSpans().length).toEqual(0); instr.enable(); - instr.onReportTTFB(TTFB, () => {}); + instr.onReportTTFB(TTFB, { applyCustomAttributes: () => {} }); expect(exporter.getFinishedSpans().length).toEqual(1); expect(exporter.getFinishedSpans()[0].name).toEqual('TTFB'); @@ -514,11 +596,13 @@ describe('Web Vitals Instrumentation Tests', () => { describe('FID', () => { it('should create a span when enabled', () => { const webVitalsInstr = new WebVitalsInstrumentation(); - webVitalsInstr.onReportFID(FID, (fid, span) => { - span.setAttributes({ - 'fid.entries': fid.entries.toString(), - 'fid.my_custom_attr': 'custom_attr', - }); + webVitalsInstr.onReportFID(FID, { + applyCustomAttributes: (fid, span) => { + span.setAttributes({ + 'fid.entries': fid.entries.toString(), + 'fid.my_custom_attr': 'custom_attr', + }); + }, }); const span = exporter.getFinishedSpans()[0]; @@ -534,11 +618,11 @@ describe('Web Vitals Instrumentation Tests', () => { vitalsToTrack: ['FID'], }); instr.disable(); - instr.onReportFID(FID, () => {}); + instr.onReportFID(FID, { applyCustomAttributes: () => {} }); expect(exporter.getFinishedSpans().length).toEqual(0); instr.enable(); - instr.onReportFID(FID, () => {}); + instr.onReportFID(FID, { applyCustomAttributes: () => {} }); expect(exporter.getFinishedSpans().length).toEqual(1); expect(exporter.getFinishedSpans()[0].name).toEqual('FID');