Skip to content

Commit fa2c607

Browse files
authored
feat: add ReportingObserver and longtask listeners (#491)
## Summary Adds two new browser listeners to `highlight.run` / `@launchdarkly/observability`, both default-on with `ObserveOptions` opt-outs: - **`ReportingObserverListener`** — subscribes to the browser Reporting API and emits `csp-violation` / `intervention` reports as errors and `deprecation` reports as warn logs. Gated on `enableReportingObserver`. - **`LongtaskListener`** — taps `PerformanceObserver({ entryTypes: ['longtask'] })` for main-thread blocks >50ms and records a `long_task.duration` histogram with container attribution. Gated on `enableLongtaskRecording`.
1 parent fcf5d1a commit fa2c607

4 files changed

Lines changed: 293 additions & 0 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
export interface LongtaskEntry {
2+
/** Duration of the long task in ms. */
3+
duration: number
4+
/** Entry name — usually `self` for same-origin tasks. */
5+
name: string
6+
/** `iframe`, `embed`, `object`, or empty when originating from the host page. */
7+
containerType?: string
8+
/** `src`/`id`/`name` of the container, when one is present. */
9+
containerSrc?: string
10+
containerId?: string
11+
containerName?: string
12+
/** startTime relative to navigationStart in ms. */
13+
startTime: number
14+
}
15+
16+
const isLongtaskSupported = (): boolean => {
17+
if (typeof window === 'undefined') return false
18+
if (typeof PerformanceObserver === 'undefined') return false
19+
const supported = (
20+
PerformanceObserver as unknown as { supportedEntryTypes?: string[] }
21+
).supportedEntryTypes
22+
return Array.isArray(supported) && supported.includes('longtask')
23+
}
24+
25+
/**
26+
* Subscribes to the browser PerformanceObserver for `longtask` entries
27+
* (main-thread blocks > 50ms) and invokes the callback for each entry.
28+
*
29+
* Returns a disconnect function. No-ops on environments that do not
30+
* support the longtask entry type.
31+
*/
32+
export const LongtaskListener = (
33+
callback: (entry: LongtaskEntry) => void,
34+
): (() => void) => {
35+
if (!isLongtaskSupported()) return () => {}
36+
37+
let observer: PerformanceObserver
38+
try {
39+
observer = new PerformanceObserver((list) => {
40+
for (const entry of list.getEntries()) {
41+
// PerformanceLongTaskTiming exposes attribution[] but we
42+
// surface the first attribution's container context which is
43+
// the common useful dimension.
44+
const attribution = (
45+
entry as PerformanceEntry & {
46+
attribution?: ReadonlyArray<{
47+
containerType?: string
48+
containerSrc?: string
49+
containerId?: string
50+
containerName?: string
51+
}>
52+
}
53+
).attribution?.[0]
54+
callback({
55+
duration: entry.duration,
56+
name: entry.name,
57+
startTime: entry.startTime,
58+
containerType: attribution?.containerType,
59+
containerSrc: attribution?.containerSrc,
60+
containerId: attribution?.containerId,
61+
containerName: attribution?.containerName,
62+
})
63+
}
64+
})
65+
observer.observe({ type: 'longtask', buffered: true })
66+
} catch {
67+
return () => {}
68+
}
69+
70+
return () => {
71+
try {
72+
observer.disconnect()
73+
} catch {
74+
// ignore
75+
}
76+
}
77+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { ConsoleMethods } from '../types/client'
2+
3+
export type ReportingObserverReportKind = 'error' | 'log'
4+
5+
export interface ReportingObserverReport {
6+
kind: ReportingObserverReportKind
7+
/** Log level when kind === 'log'. */
8+
level?: ConsoleMethods
9+
/** `deprecation`, `intervention`, `csp-violation`, ... */
10+
type: string
11+
/** Human-readable message for error/log routing. */
12+
message: string
13+
/** URL the report originated from. */
14+
url?: string
15+
/** Flattened `report.body` fields (prefixed with `report.body.`). */
16+
attributes: Record<string, string | number | boolean | undefined>
17+
}
18+
19+
const BODY_PRIMITIVE = new Set(['string', 'number', 'boolean'])
20+
21+
const isReportingObserverSupported = (): boolean =>
22+
typeof window !== 'undefined' &&
23+
typeof (window as unknown as { ReportingObserver?: unknown })
24+
.ReportingObserver !== 'undefined'
25+
26+
const flattenBody = (
27+
body: unknown,
28+
): Record<string, string | number | boolean> => {
29+
const attrs: Record<string, string | number | boolean> = {}
30+
if (!body || typeof body !== 'object') return attrs
31+
for (const [key, value] of Object.entries(
32+
body as Record<string, unknown>,
33+
)) {
34+
if (value === null || value === undefined) continue
35+
if (BODY_PRIMITIVE.has(typeof value)) {
36+
attrs[`report.body.${key}`] = value as string | number | boolean
37+
} else {
38+
try {
39+
attrs[`report.body.${key}`] = JSON.stringify(value)
40+
} catch {
41+
// ignore un-serializable fields
42+
}
43+
}
44+
}
45+
return attrs
46+
}
47+
48+
const messageFromReport = (report: {
49+
type: string
50+
body?: Record<string, unknown> | null
51+
}): string => {
52+
const body = report.body ?? {}
53+
const message =
54+
(body['message'] as string | undefined) ||
55+
(body['reason'] as string | undefined) ||
56+
(body['id'] as string | undefined) ||
57+
report.type
58+
return typeof message === 'string' ? message : report.type
59+
}
60+
61+
export interface ReportingObserverListenerOptions {
62+
/**
63+
* Which report types to observe. Defaults to all three browser types
64+
* that contribute actionable diagnostics.
65+
*/
66+
types?: string[]
67+
}
68+
69+
/**
70+
* Subscribes to the browser's Reporting API and fans reports out to the
71+
* provided callback. CSP and intervention reports are emitted as errors;
72+
* deprecation reports are emitted as warn-level logs.
73+
*
74+
* Returns a disconnect function. If `ReportingObserver` is unavailable in
75+
* the current environment, returns a no-op.
76+
*/
77+
export const ReportingObserverListener = (
78+
callback: (report: ReportingObserverReport) => void,
79+
options?: ReportingObserverListenerOptions,
80+
): (() => void) => {
81+
if (!isReportingObserverSupported()) return () => {}
82+
83+
const types = options?.types ?? [
84+
'deprecation',
85+
'intervention',
86+
'csp-violation',
87+
]
88+
89+
const ReportingObserverCtor = (
90+
window as unknown as {
91+
ReportingObserver: new (
92+
cb: (reports: ReadonlyArray<any>) => void,
93+
opts: { buffered?: boolean; types?: string[] },
94+
) => { observe(): void; disconnect(): void }
95+
}
96+
).ReportingObserver
97+
98+
let observer: { observe(): void; disconnect(): void }
99+
try {
100+
observer = new ReportingObserverCtor(
101+
(reports) => {
102+
for (const report of reports) {
103+
const type = report.type ?? 'unknown'
104+
const body = (report.body ?? {}) as Record<string, unknown>
105+
const attributes = {
106+
'report.type': type,
107+
'report.url': report.url,
108+
...flattenBody(body),
109+
}
110+
const message = messageFromReport({ type, body })
111+
if (type === 'deprecation') {
112+
callback({
113+
kind: 'log',
114+
level: 'warn',
115+
type,
116+
message,
117+
url: report.url,
118+
attributes,
119+
})
120+
} else {
121+
callback({
122+
kind: 'error',
123+
type,
124+
message,
125+
url: report.url,
126+
attributes,
127+
})
128+
}
129+
}
130+
},
131+
{ buffered: true, types },
132+
)
133+
observer.observe()
134+
} catch {
135+
return () => {}
136+
}
137+
138+
return () => {
139+
try {
140+
observer.disconnect()
141+
} catch {
142+
// ignore
143+
}
144+
}
145+
}

sdk/highlight-run/src/client/types/observe.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ export type ObserveOptions = CommonOptions & {
4040
* @default true
4141
*/
4242
enablePerformanceRecording?: boolean
43+
/**
44+
* Specifies whether to record main-thread `longtask` entries (>50ms) as
45+
* `long_task.duration` histogram samples.
46+
* @default true
47+
*/
48+
enableLongtaskRecording?: boolean
49+
/**
50+
* Specifies whether to subscribe to the browser Reporting API and emit
51+
* CSP/intervention reports as errors and deprecation reports as warn logs.
52+
* @default true
53+
*/
54+
enableReportingObserver?: boolean
4355
/**
4456
* Specifies the environment your application is running in.
4557
* This is useful to distinguish whether your session was recorded on localhost or in production.

sdk/highlight-run/src/sdk/observe.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ import {
7575
NetworkPerformanceListener,
7676
NetworkPerformancePayload,
7777
} from '../client/listeners/network-listener/performance-listener'
78+
import { LongtaskListener } from '../client/listeners/longtask-listener'
79+
import { ReportingObserverListener } from '../client/listeners/reporting-observer-listener'
7880
import randomUuidV4 from '../client/utils/randomUuidV4'
7981
import { recordException } from '../client/otel/recordException'
8082
import { ObserveOptions, ProductAnalyticsEvents } from '../client/types/observe'
@@ -682,6 +684,63 @@ export class ObserveSDK implements Observe {
682684
},
683685
})
684686
}
687+
if (this._options.enableLongtaskRecording !== false) {
688+
LongtaskListener((entry) => {
689+
const attributes: Attributes = {
690+
category: MetricCategory.Performance,
691+
name: entry.name,
692+
[SemanticAttributes.ATTR_URL_PATH]:
693+
window.location.pathname,
694+
}
695+
if (entry.containerType) {
696+
attributes['container_type'] = entry.containerType
697+
}
698+
if (entry.containerSrc) {
699+
attributes['container_src'] = entry.containerSrc
700+
}
701+
if (entry.containerId) {
702+
attributes['container_id'] = entry.containerId
703+
}
704+
if (entry.containerName) {
705+
attributes['container_name'] = entry.containerName
706+
}
707+
this.recordHistogram({
708+
name: 'long_task.duration',
709+
value: entry.duration,
710+
attributes,
711+
})
712+
})
713+
}
714+
if (this._options.enableReportingObserver !== false) {
715+
ReportingObserverListener((report) => {
716+
const attributes: Attributes = {
717+
...report.attributes,
718+
[SemanticAttributes.ATTR_URL_PATH]:
719+
window.location.pathname,
720+
}
721+
if (report.kind === 'log') {
722+
this._recordLog(
723+
report.message,
724+
report.level ?? 'warn',
725+
attributes,
726+
)
727+
} else {
728+
const err = new Error(report.message)
729+
this.recordError(
730+
err,
731+
undefined,
732+
Object.fromEntries(
733+
Object.entries(attributes).map(([k, v]) => [
734+
k,
735+
v === undefined ? '' : String(v),
736+
]),
737+
),
738+
'reporting-observer',
739+
'custom',
740+
)
741+
}
742+
})
743+
}
685744
}
686745

687746
recordLog(message: any, level: ConsoleMethods, metadata?: Attributes) {

0 commit comments

Comments
 (0)