Skip to content
This repository was archived by the owner on Sep 30, 2024. It is now read-only.

Commit 6fd138c

Browse files
web: add WindowLoad OpenTelemetry auto instrumentation (#40188)
1 parent 4624ea8 commit 6fd138c

File tree

9 files changed

+341
-1
lines changed

9 files changed

+341
-1
lines changed

client/web/src/monitoring/opentelemetry/initOpenTelemetry.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'
1313
import isAbsoluteUrl from 'is-absolute-url'
1414

1515
import { ConsoleBatchSpanExporter } from './exporters/consoleBatchSpanExporter'
16+
import { WindowLoadInstrumentation } from './instrumentations/window-load'
1617

1718
export function initOpenTelemetry(): void {
1819
const { openTelemetry, externalURL } = window.context
@@ -45,7 +46,10 @@ export function initOpenTelemetry(): void {
4546

4647
registerInstrumentations({
4748
// Type-casting is required since the `FetchInstrumentation` is wrongly typed internally as `node.js` instrumentation.
48-
instrumentations: [(new FetchInstrumentation() as unknown) as InstrumentationOption],
49+
instrumentations: [
50+
(new FetchInstrumentation() as unknown) as InstrumentationOption,
51+
new WindowLoadInstrumentation(),
52+
],
4953
})
5054
}
5155
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export enum WindowLoadSpanName {
2+
WINDOW_LOAD = 'windowLoad',
3+
DOCUMENT_FETCH = 'documentFetch',
4+
RESOURCE_FETCH = 'resourceFetch',
5+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { propagation, Span, ROOT_CONTEXT } from '@opentelemetry/api'
2+
import { otperformance } from '@opentelemetry/core'
3+
import { PerformanceTimingNames } from '@opentelemetry/sdk-trace-web'
4+
import { SemanticAttributes } from '@opentelemetry/semantic-conventions'
5+
6+
import {
7+
addTimeEventsToSpan,
8+
addSpanPerformancePaintEvents,
9+
performanceNavigationTimingToEntries,
10+
getServerSideTraceParent,
11+
ActiveSpanConfig,
12+
InstrumentationBaseWeb,
13+
} from '../sdk'
14+
15+
import { WindowLoadSpanName } from './constants'
16+
17+
/**
18+
* Auto instrumentation of the window load event based on Web Performance API `navigation` entries.
19+
*
20+
* 1. Listens to the first performance `navigation` entries update.
21+
* 2. Creates the `WINDOW_LOAD` span capturing timings from `FETCH_START` till `LOAD_EVENT_END`.
22+
* 3. Adds performance `navigation` events to the `WINDOW_LOAD` span.
23+
* 4. Adds performance `paint` events to the `WINDOW_LOAD` span.
24+
* 5. Creates nested spans for all resources loaded before the `LOAD_EVENT_END`.
25+
*
26+
* See Navigation Timing API documentation
27+
* https://developer.mozilla.org/en-US/docs/Web/API/Navigation_timing_API
28+
*
29+
* See Navigation Timing spec processing model:
30+
* https://www.w3.org/TR/navigation-timing-2/#processing-model
31+
*
32+
* See Resource Timing spec processing model:
33+
* https://www.w3.org/TR/resource-timing-2/#attribute-descriptions
34+
*
35+
* Based on the OpenTelemetry Instrumentation Document Load
36+
* https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/web/opentelemetry-instrumentation-document-load
37+
*
38+
* The implementation is forked because of various issues blocking
39+
* the integration with other auto instrumentations.
40+
*
41+
* RUM integration: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/732
42+
* Fetch integration: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/995
43+
*/
44+
export class WindowLoadInstrumentation extends InstrumentationBaseWeb {
45+
public static instrumentationName = '@sourcegraph/instrumentation-window-load'
46+
public static version = '0.1'
47+
48+
// The PerformanceObserver listener is executed once for the first `navigation` list update.
49+
private observer = new PerformanceObserver((_list, observer) => {
50+
this.collectPerformance()
51+
observer.disconnect()
52+
})
53+
54+
constructor() {
55+
super(WindowLoadInstrumentation.instrumentationName, WindowLoadInstrumentation.version)
56+
}
57+
58+
private addResourcesSpans(parentSpan: Span): void {
59+
// Casting is required until this issue is resolved: https://github.com/microsoft/TypeScript/issues/33866
60+
for (const resource of otperformance.getEntriesByType('resource') as PerformanceResourceTiming[]) {
61+
this.createFinishedSpan({
62+
name: WindowLoadSpanName.RESOURCE_FETCH,
63+
startTime: resource[PerformanceTimingNames.FETCH_START],
64+
endTime: resource[PerformanceTimingNames.RESPONSE_END],
65+
parentSpan,
66+
networkEvents: resource,
67+
attributes: {
68+
[SemanticAttributes.HTTP_URL]: resource.name,
69+
},
70+
})
71+
}
72+
}
73+
74+
private collectPerformance(): void {
75+
const entries = performanceNavigationTimingToEntries()
76+
const rootContext = propagation.extract(ROOT_CONTEXT, { traceparent: getServerSideTraceParent() })
77+
78+
const rootSpanConfig: ActiveSpanConfig = {
79+
name: WindowLoadSpanName.WINDOW_LOAD,
80+
startTime: entries[PerformanceTimingNames.FETCH_START],
81+
context: rootContext,
82+
attributes: {
83+
[SemanticAttributes.HTTP_URL]: location.href,
84+
[SemanticAttributes.HTTP_USER_AGENT]: navigator.userAgent,
85+
},
86+
}
87+
88+
this.createActiveSpan(rootSpanConfig, rootSpan => {
89+
addTimeEventsToSpan(rootSpan, entries, [
90+
PerformanceTimingNames.FETCH_START,
91+
PerformanceTimingNames.UNLOAD_EVENT_START,
92+
PerformanceTimingNames.UNLOAD_EVENT_END,
93+
PerformanceTimingNames.DOM_INTERACTIVE,
94+
PerformanceTimingNames.DOM_CONTENT_LOADED_EVENT_START,
95+
PerformanceTimingNames.DOM_CONTENT_LOADED_EVENT_END,
96+
PerformanceTimingNames.DOM_COMPLETE,
97+
PerformanceTimingNames.LOAD_EVENT_START,
98+
PerformanceTimingNames.LOAD_EVENT_END,
99+
])
100+
101+
addSpanPerformancePaintEvents(rootSpan)
102+
103+
this.addResourcesSpans(rootSpan)
104+
this.createFinishedSpan({
105+
name: WindowLoadSpanName.DOCUMENT_FETCH,
106+
startTime: entries[PerformanceTimingNames.FETCH_START],
107+
endTime: entries[PerformanceTimingNames.RESPONSE_END],
108+
networkEvents: entries,
109+
parentSpan: rootSpan,
110+
})
111+
112+
rootSpan.end(entries[PerformanceTimingNames.LOAD_EVENT_END])
113+
})
114+
}
115+
116+
public enable(): void {
117+
if (window.document.readyState === 'complete') {
118+
return this.collectPerformance()
119+
}
120+
121+
this.observer.observe({ type: 'navigation' })
122+
}
123+
124+
public disable(): void {
125+
this.observer.disconnect()
126+
}
127+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { context, trace, Tracer, ROOT_CONTEXT, SpanOptions, Context, Span } from '@opentelemetry/api'
2+
3+
export interface ActiveSpanConfig extends SpanOptions {
4+
name: string
5+
startTime?: number
6+
endTime?: number
7+
parentSpan?: Span
8+
context?: Context
9+
}
10+
11+
/**
12+
* Creates span, links to a parent span, calls callback in the new span context.
13+
* A helper to use with the Web Performance API where the `endTime` is often available right away.
14+
*
15+
* See https://opentelemetry.io/docs/instrumentation/js/instrumentation/#create-nested-spans
16+
*/
17+
export function createActiveSpan<F extends (span: Span) => unknown>(
18+
tracer: Tracer,
19+
config: ActiveSpanConfig,
20+
callback: F
21+
): ReturnType<F> | null {
22+
const { name, startTime, parentSpan, context: spanContext = ROOT_CONTEXT, ...restSpanOptions } = config
23+
24+
if (typeof startTime === 'undefined') {
25+
return null
26+
}
27+
28+
const resultContext = parentSpan ? trace.setSpan(context.active(), parentSpan) : spanContext
29+
30+
return tracer.startActiveSpan<F>(
31+
name,
32+
{
33+
startTime,
34+
...restSpanOptions,
35+
},
36+
resultContext,
37+
callback
38+
)
39+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { context, trace, Tracer, ROOT_CONTEXT, SpanOptions, Context, Span } from '@opentelemetry/api'
2+
import { addSpanNetworkEvents, PerformanceEntries } from '@opentelemetry/sdk-trace-web'
3+
4+
export interface FinishedSpanConfig extends SpanOptions {
5+
name: string
6+
startTime?: number
7+
endTime?: number
8+
parentSpan?: Span
9+
context?: Context
10+
networkEvents?: PerformanceEntries
11+
}
12+
13+
/**
14+
* Creates span, links to a parent span, adds network events, ends the span.
15+
* A helper to use with the Web Performance API where the `endTime` is often available right away.
16+
*
17+
* See https://developer.mozilla.org/en-US/docs/Web/API/Performance
18+
*/
19+
export function createFinishedSpan(tracer: Tracer, config: FinishedSpanConfig): Span | null {
20+
const {
21+
name,
22+
startTime,
23+
endTime,
24+
parentSpan,
25+
context: spanContext = ROOT_CONTEXT,
26+
networkEvents,
27+
...restSpanOptions
28+
} = config
29+
30+
if (typeof startTime === 'undefined' || typeof endTime === 'undefined') {
31+
return null
32+
}
33+
34+
const resultContext = parentSpan ? trace.setSpan(context.active(), parentSpan) : spanContext
35+
36+
const span = tracer.startSpan(
37+
name,
38+
{
39+
startTime,
40+
...restSpanOptions,
41+
},
42+
resultContext
43+
)
44+
45+
if (networkEvents) {
46+
addSpanNetworkEvents(span, networkEvents)
47+
}
48+
49+
span.end(endTime)
50+
51+
return span
52+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from './performance'
2+
export * from './trace'
3+
export * from './createActiveSpan'
4+
export * from './createFinishedSpan'
5+
export * from './instrumentation-base-web'
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Instrumentation, InstrumentationConfig, InstrumentationBase } from '@opentelemetry/instrumentation'
2+
3+
import { createActiveSpan } from './createActiveSpan'
4+
import { createFinishedSpan } from './createFinishedSpan'
5+
6+
/**
7+
* Base abstract class for instrumenting Sourcegraph OpenTelemetry web plugins.
8+
*
9+
* The implementation is based on
10+
* https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation
11+
*/
12+
export abstract class InstrumentationBaseWeb extends InstrumentationBase implements Instrumentation {
13+
protected createActiveSpan = createActiveSpan.bind(this, this.tracer)
14+
protected createFinishedSpan = createFinishedSpan.bind(this, this.tracer)
15+
16+
constructor(
17+
instrumentationName: string,
18+
instrumentationVersion: string,
19+
// Do not enable instrumentation by default until `registerInstrumentations` call.
20+
config: InstrumentationConfig = { enabled: false }
21+
) {
22+
super(instrumentationName, instrumentationVersion, config)
23+
}
24+
25+
public init(): void {
26+
/** noop, the abstract method is defined for overriding modules in node.js */
27+
}
28+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Span } from '@opentelemetry/api'
2+
import { otperformance } from '@opentelemetry/core'
3+
import { hasKey, PerformanceEntries, PerformanceTimingNames } from '@opentelemetry/sdk-trace-web'
4+
import { camelCase } from 'lodash'
5+
6+
/**
7+
* Picks `navigation` performance entries matching keys specified in `PerformanceTimingNames`.
8+
*
9+
* See https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming
10+
*/
11+
export function performanceNavigationTimingToEntries(): PerformanceEntries {
12+
const [timing] = otperformance.getEntriesByType('navigation') as PerformanceNavigationTiming[]
13+
14+
return Object.values(PerformanceTimingNames).reduce<PerformanceEntries>((result, key) => {
15+
if (timing && hasKey(timing, key)) {
16+
const value = timing[key]
17+
18+
if (typeof value === 'number') {
19+
result[key] = value
20+
}
21+
}
22+
23+
return result
24+
}, {})
25+
}
26+
27+
/**
28+
* If `paint` performance entries are available adds `first-paint`
29+
* and `first-contentful-paint` events if to the span.
30+
*
31+
* See https://developer.mozilla.org/en-US/docs/Web/API/PerformancePaintTiming
32+
*/
33+
export const addSpanPerformancePaintEvents = (span: Span): void => {
34+
for (const { name, startTime } of otperformance.getEntriesByType('paint')) {
35+
span.addEvent(camelCase(name), startTime)
36+
}
37+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { context, trace, Span, TimeInput } from '@opentelemetry/api'
2+
import { TRACE_PARENT_HEADER } from '@opentelemetry/core'
3+
4+
/**
5+
* Parses `traceParent` - a meta property that comes from server.
6+
* It should be dynamically generated server side to have the server's request trace Id,
7+
* a parent span Id that was set on the server's request span.
8+
*
9+
* See https://opentelemetry.io/docs/instrumentation/js/getting-started/browser/
10+
*/
11+
export function getServerSideTraceParent(): string {
12+
const metaElement = Array.from(document.querySelectorAll('meta')).find(
13+
element => element.getAttribute('name') === TRACE_PARENT_HEADER
14+
)
15+
16+
return metaElement?.content || ''
17+
}
18+
19+
/**
20+
* Sets span as active and executes the callback in span's context.
21+
*
22+
* See https://github.com/open-telemetry/opentelemetry-js-api/blob/main/docs/tracing.md#describing-a-span
23+
*/
24+
export function runInSpanContext(span: Span, callback: () => void): void {
25+
context.with(trace.setSpan(context.active(), span), callback)
26+
}
27+
28+
/**
29+
* Adds defined time events to span.
30+
*
31+
* See https://opentelemetry.io/docs/concepts/signals/traces/#span-events
32+
*/
33+
export function addTimeEventsToSpan<T extends Record<string, TimeInput | undefined>>(
34+
span: Span,
35+
timeEvents: T,
36+
names: Extract<keyof T, string>[]
37+
): void {
38+
for (const name of names) {
39+
if (typeof timeEvents[name] !== 'undefined') {
40+
span.addEvent(name, timeEvents[name])
41+
}
42+
}
43+
}

0 commit comments

Comments
 (0)