Skip to content

Commit f71b13a

Browse files
authored
Set up frontend instrumentation and other OTel related refactorings (#2247)
* Add OTLP proxy endpoint * Fix OtlpProxyOptions * Refactor * Fix CodeQL * Refactor and dispose disposable * Set up frontend instrumentation and other OTel related refactorings * Apply suggestion from @reakaleek * Remove redundant custom attributes on FetchInstrumentation * Use logs instead of traces for search_result_clicked events
1 parent f2dc9d0 commit f71b13a

File tree

34 files changed

+2015
-242
lines changed

34 files changed

+2015
-242
lines changed

src/Elastic.Documentation.Site/Assets/custom-elements.ts

Lines changed: 0 additions & 2 deletions
This file was deleted.

src/Elastic.Documentation.Site/Assets/image-carousel.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,6 @@ class ImageCarousel {
208208

209209
this.prevButton.style.top = `${controlTop}px`
210210
this.nextButton.style.top = `${controlTop}px`
211-
212-
// Debug logging (remove in production)
213-
console.log(
214-
`Carousel controls positioned: minHeight=${minHeight}px, controlTop=${controlTop}px`
215-
)
216211
}
217212
}
218213
}

src/Elastic.Documentation.Site/Assets/main.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,33 @@ import { openDetailsWithAnchor } from './open-details-with-anchor'
77
import { initNav } from './pages-nav'
88
import { initSmoothScroll } from './smooth-scroll'
99
import { initTabs } from './tabs'
10+
import { initializeOtel } from './telemetry/instrumentation'
1011
import { initTocNav } from './toc-nav'
1112
import 'htmx-ext-head-support'
1213
import 'htmx-ext-preload'
1314
import * as katex from 'katex'
1415
import { $, $$ } from 'select-dom'
1516
import { UAParser } from 'ua-parser-js'
1617

18+
// Injected at build time from MinVer
19+
const DOCS_BUILDER_VERSION =
20+
process.env.DOCS_BUILDER_VERSION?.trim() ?? '0.0.0-dev'
21+
22+
// Initialize OpenTelemetry FIRST, before any other code runs
23+
// This must happen early so all subsequent code is instrumented
24+
initializeOtel({
25+
serviceName: 'docs-frontend',
26+
serviceVersion: DOCS_BUILDER_VERSION,
27+
baseUrl: '/docs',
28+
debug: false,
29+
})
30+
31+
// Dynamically import web components after telemetry is initialized
32+
// This ensures telemetry is available when the components execute
33+
// Parcel will automatically code-split this into a separate chunk
34+
import('./web-components/SearchOrAskAi/SearchOrAskAi')
35+
import('./web-components/VersionDropdown')
36+
1737
const { getOS } = new UAParser()
1838
const isLazyLoadNavigationEnabled =
1939
$('meta[property="docs:feature:lazy-load-navigation"]')?.content === 'true'

src/Elastic.Documentation.Site/Assets/open-details-with-anchor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { UAParser } from 'ua-parser-js'
22

3-
const { browser } = UAParser()
3+
const parser = new UAParser()
4+
const browser = parser.getBrowser()
45

56
// This is a fix for anchors in details elements in non-Chrome browsers.
67
export function openDetailsWithAnchor() {
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
/**
2+
* OpenTelemetry configuration for frontend telemetry.
3+
* Sends traces and logs to the backend OTLP proxy endpoint.
4+
*
5+
* This module should be imported once at application startup.
6+
* All web components will automatically be instrumented once initialized.
7+
*
8+
* Inspired by: https://signoz.io/docs/frontend-monitoring/sending-logs-with-opentelemetry/
9+
*/
10+
import { logs } from '@opentelemetry/api-logs'
11+
import { ZoneContextManager } from '@opentelemetry/context-zone'
12+
import { W3CTraceContextPropagator } from '@opentelemetry/core'
13+
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'
14+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
15+
import { registerInstrumentations } from '@opentelemetry/instrumentation'
16+
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch'
17+
import { resourceFromAttributes } from '@opentelemetry/resources'
18+
import {
19+
LoggerProvider,
20+
BatchLogRecordProcessor,
21+
} from '@opentelemetry/sdk-logs'
22+
import {
23+
WebTracerProvider,
24+
BatchSpanProcessor,
25+
SpanProcessor,
26+
Span,
27+
} from '@opentelemetry/sdk-trace-web'
28+
import {
29+
ATTR_SERVICE_NAME,
30+
ATTR_SERVICE_VERSION,
31+
} from '@opentelemetry/semantic-conventions'
32+
33+
let isInitialized = false
34+
let traceProvider: WebTracerProvider | null = null
35+
let loggerProvider: LoggerProvider | null = null
36+
37+
export function initializeOtel(options: OtelConfigOptions = {}): boolean {
38+
if (isAlreadyInitialized()) return false
39+
40+
markAsInitialized()
41+
42+
const config = resolveConfiguration(options)
43+
logInitializationStart(config)
44+
45+
try {
46+
const resource = createSharedResource(config)
47+
const commonHeaders = createCommonHeaders()
48+
49+
initializeTracing(resource, config, commonHeaders)
50+
initializeLogging(resource, config, commonHeaders)
51+
52+
setupAutoFlush(config.debug)
53+
logInitializationSuccess(config)
54+
55+
return true
56+
} catch (error) {
57+
logInitializationError(error)
58+
isInitialized = false
59+
return false
60+
}
61+
}
62+
63+
function isAlreadyInitialized(): boolean {
64+
if (isInitialized) {
65+
console.warn(
66+
'OpenTelemetry already initialized. Skipping re-initialization.'
67+
)
68+
return true
69+
}
70+
return false
71+
}
72+
73+
function markAsInitialized(): void {
74+
isInitialized = true
75+
}
76+
77+
function resolveConfiguration(options: OtelConfigOptions): ResolvedConfig {
78+
return {
79+
serviceName: options.serviceName ?? 'docs-frontend',
80+
serviceVersion: options.serviceVersion ?? '1.0.0',
81+
baseUrl: options.baseUrl ?? window.location.origin,
82+
debug: options.debug ?? false,
83+
}
84+
}
85+
86+
function logInitializationStart(config: ResolvedConfig): void {
87+
if (config.debug) {
88+
// eslint-disable-next-line no-console
89+
console.log('[OTEL] Initializing OpenTelemetry with config:', config)
90+
}
91+
}
92+
93+
function createSharedResource(config: ResolvedConfig) {
94+
const resourceAttributes: Record<string, string> = {
95+
[ATTR_SERVICE_NAME]: config.serviceName,
96+
[ATTR_SERVICE_VERSION]: config.serviceVersion,
97+
}
98+
return resourceFromAttributes(resourceAttributes)
99+
}
100+
101+
function createCommonHeaders(): Record<string, string> {
102+
return {
103+
'X-Docs-Session': 'active',
104+
}
105+
}
106+
107+
function initializeTracing(
108+
resource: ReturnType<typeof resourceFromAttributes>,
109+
config: ResolvedConfig,
110+
commonHeaders: Record<string, string>
111+
): void {
112+
const traceExporter = new OTLPTraceExporter({
113+
url: `${config.baseUrl}/_api/v1/o/t`,
114+
headers: { ...commonHeaders },
115+
})
116+
117+
const spanProcessor = new BatchSpanProcessor(traceExporter)
118+
const euidProcessor = new EuidSpanProcessor()
119+
120+
traceProvider = new WebTracerProvider({
121+
resource,
122+
spanProcessors: [euidProcessor, spanProcessor],
123+
})
124+
125+
traceProvider.register({
126+
contextManager: new ZoneContextManager(),
127+
propagator: new W3CTraceContextPropagator(),
128+
})
129+
130+
registerFetchInstrumentation()
131+
}
132+
133+
function registerFetchInstrumentation(): void {
134+
registerInstrumentations({
135+
instrumentations: [
136+
new FetchInstrumentation({
137+
propagateTraceHeaderCorsUrls: [
138+
new RegExp(`${window.location.origin}/.*`),
139+
],
140+
ignoreUrls: [
141+
/_api\/v1\/o\/.*/,
142+
/_api\/v1\/?$/,
143+
/__parcel_code_frame$/,
144+
],
145+
}),
146+
],
147+
})
148+
}
149+
150+
function initializeLogging(
151+
resource: ReturnType<typeof resourceFromAttributes>,
152+
config: ResolvedConfig,
153+
commonHeaders: Record<string, string>
154+
): void {
155+
const logExporter = new OTLPLogExporter({
156+
url: `${config.baseUrl}/_api/v1/o/l`,
157+
headers: { ...commonHeaders },
158+
})
159+
160+
const logProcessor = new BatchLogRecordProcessor(logExporter)
161+
162+
loggerProvider = new LoggerProvider({
163+
resource,
164+
processors: [logProcessor],
165+
})
166+
167+
logs.setGlobalLoggerProvider(loggerProvider)
168+
}
169+
170+
function setupAutoFlush(debug: boolean = false) {
171+
let isFlushing = false
172+
173+
const performFlush = async () => {
174+
if (isFlushing || !isInitialized) {
175+
return
176+
}
177+
178+
isFlushing = true
179+
180+
if (debug) {
181+
// eslint-disable-next-line no-console
182+
console.log(
183+
'[OTEL] Auto-flushing telemetry (visibilitychange or pagehide)'
184+
)
185+
}
186+
187+
try {
188+
await flushTelemetry()
189+
} catch (error) {
190+
if (debug) {
191+
console.warn('[OTEL] Error during auto-flush:', error)
192+
}
193+
} finally {
194+
isFlushing = false
195+
}
196+
}
197+
198+
document.addEventListener('visibilitychange', () => {
199+
if (document.visibilityState === 'hidden') {
200+
performFlush()
201+
}
202+
})
203+
204+
window.addEventListener('pagehide', performFlush)
205+
206+
if (debug) {
207+
// eslint-disable-next-line no-console
208+
console.log('[OTEL] Auto-flush event listeners registered')
209+
// eslint-disable-next-line no-console
210+
console.log(
211+
'[OTEL] Using OTLP HTTP exporters with keepalive for guaranteed delivery'
212+
)
213+
}
214+
}
215+
216+
async function flushTelemetry(timeoutMs: number = 1000): Promise<void> {
217+
if (!isInitialized) {
218+
return
219+
}
220+
221+
const flushPromises: Promise<void>[] = []
222+
223+
if (traceProvider) {
224+
flushPromises.push(
225+
traceProvider.forceFlush().catch((err) => {
226+
console.warn('[OTEL] Failed to flush traces:', err)
227+
})
228+
)
229+
}
230+
231+
if (loggerProvider) {
232+
flushPromises.push(
233+
loggerProvider.forceFlush().catch((err) => {
234+
console.warn('[OTEL] Failed to flush logs:', err)
235+
})
236+
)
237+
}
238+
239+
await Promise.race([
240+
Promise.all(flushPromises),
241+
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
242+
])
243+
}
244+
245+
function logInitializationSuccess(config: ResolvedConfig): void {
246+
if (config.debug) {
247+
// eslint-disable-next-line no-console
248+
console.log('[OTEL] OpenTelemetry initialized successfully', {
249+
serviceName: config.serviceName,
250+
serviceVersion: config.serviceVersion,
251+
traceEndpoint: `${config.baseUrl}/_api/v1/o/t`,
252+
logEndpoint: `${config.baseUrl}/_api/v1/o/l`,
253+
autoFlushOnUnload: true,
254+
})
255+
}
256+
}
257+
258+
function logInitializationError(error: unknown): void {
259+
console.error('[OTEL] Failed to initialize OpenTelemetry:', error)
260+
}
261+
262+
function getCookie(name: string): string | null {
263+
const value = `; ${document.cookie}`
264+
const parts = value.split(`; ${name}=`)
265+
if (parts.length === 2) return parts.pop()?.split(';').shift() || null
266+
return null
267+
}
268+
269+
class EuidSpanProcessor implements SpanProcessor {
270+
onStart(span: Span): void {
271+
const euid = getCookie('euid')
272+
if (euid) {
273+
span.setAttribute('user.euid', euid)
274+
}
275+
}
276+
277+
onEnd(): void {}
278+
279+
shutdown(): Promise<void> {
280+
return Promise.resolve()
281+
}
282+
283+
forceFlush(): Promise<void> {
284+
return Promise.resolve()
285+
}
286+
}
287+
288+
export interface OtelConfigOptions {
289+
serviceName?: string
290+
serviceVersion?: string
291+
baseUrl?: string
292+
debug?: boolean
293+
}
294+
295+
interface ResolvedConfig {
296+
serviceName: string
297+
serviceVersion: string
298+
baseUrl: string
299+
debug: boolean
300+
}

0 commit comments

Comments
 (0)