Skip to content

Commit 45f6f4d

Browse files
committed
Set up frontend instrumentation and other OTel related refactorings
1 parent 05fbd75 commit 45f6f4d

38 files changed

+2030
-248
lines changed

Directory.Packages.props

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@
4242
<PackageVersion Include="Microsoft.OpenApi" Version="3.0.1" />
4343
<PackageVersion Include="TUnit" Version="0.25.21" />
4444
<PackageVersion Include="xunit.v3.extensibility.core" Version="2.0.2" />
45-
<PackageVersion Include="WireMock.Net" Version="1.6.11" />
4645
</ItemGroup>
4746
<!-- Build -->
4847
<ItemGroup>

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: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
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+
applyCustomAttributesOnSpan: (span, request, result) => {
146+
span.setAttribute('http.method', request.method || 'GET')
147+
if (result instanceof Response) {
148+
span.setAttribute('http.status_code', result.status)
149+
}
150+
},
151+
}),
152+
],
153+
})
154+
}
155+
156+
function initializeLogging(
157+
resource: ReturnType<typeof resourceFromAttributes>,
158+
config: ResolvedConfig,
159+
commonHeaders: Record<string, string>
160+
): void {
161+
const logExporter = new OTLPLogExporter({
162+
url: `${config.baseUrl}/_api/v1/o/l`,
163+
headers: { ...commonHeaders },
164+
})
165+
166+
const logProcessor = new BatchLogRecordProcessor(logExporter)
167+
168+
loggerProvider = new LoggerProvider({
169+
resource,
170+
processors: [logProcessor],
171+
})
172+
173+
logs.setGlobalLoggerProvider(loggerProvider)
174+
}
175+
176+
function setupAutoFlush(debug: boolean = false) {
177+
let isFlushing = false
178+
179+
const performFlush = async () => {
180+
if (isFlushing || !isInitialized) {
181+
return
182+
}
183+
184+
isFlushing = true
185+
186+
if (debug) {
187+
// eslint-disable-next-line no-console
188+
console.log(
189+
'[OTEL] Auto-flushing telemetry (visibilitychange or pagehide)'
190+
)
191+
}
192+
193+
try {
194+
await flushTelemetry()
195+
} catch (error) {
196+
if (debug) {
197+
console.warn('[OTEL] Error during auto-flush:', error)
198+
}
199+
} finally {
200+
isFlushing = false
201+
}
202+
}
203+
204+
document.addEventListener('visibilitychange', () => {
205+
if (document.visibilityState === 'hidden') {
206+
performFlush()
207+
}
208+
})
209+
210+
window.addEventListener('pagehide', performFlush)
211+
212+
if (debug) {
213+
// eslint-disable-next-line no-console
214+
console.log('[OTEL] Auto-flush event listeners registered')
215+
// eslint-disable-next-line no-console
216+
console.log(
217+
'[OTEL] Using OTLP HTTP exporters with keepalive for guaranteed delivery'
218+
)
219+
}
220+
}
221+
222+
async function flushTelemetry(timeoutMs: number = 1000): Promise<void> {
223+
if (!isInitialized) {
224+
return
225+
}
226+
227+
const flushPromises: Promise<void>[] = []
228+
229+
if (traceProvider) {
230+
flushPromises.push(
231+
traceProvider.forceFlush().catch((err) => {
232+
console.warn('[OTEL] Failed to flush traces:', err)
233+
})
234+
)
235+
}
236+
237+
if (loggerProvider) {
238+
flushPromises.push(
239+
loggerProvider.forceFlush().catch((err) => {
240+
console.warn('[OTEL] Failed to flush logs:', err)
241+
})
242+
)
243+
}
244+
245+
await Promise.race([
246+
Promise.all(flushPromises),
247+
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
248+
])
249+
}
250+
251+
function logInitializationSuccess(config: ResolvedConfig): void {
252+
if (config.debug) {
253+
// eslint-disable-next-line no-console
254+
console.log('[OTEL] OpenTelemetry initialized successfully', {
255+
serviceName: config.serviceName,
256+
serviceVersion: config.serviceVersion,
257+
traceEndpoint: `${config.baseUrl}/_api/v1/o/t`,
258+
logEndpoint: `${config.baseUrl}/_api/v1/o/l`,
259+
autoFlushOnUnload: true,
260+
})
261+
}
262+
}
263+
264+
function logInitializationError(error: unknown): void {
265+
console.error('[OTEL] Failed to initialize OpenTelemetry:', error)
266+
}
267+
268+
function getCookie(name: string): string | null {
269+
const value = `; ${document.cookie}`
270+
const parts = value.split(`; ${name}=`)
271+
if (parts.length === 2) return parts.pop()?.split(';').shift() || null
272+
return null
273+
}
274+
275+
class EuidSpanProcessor implements SpanProcessor {
276+
onStart(span: Span): void {
277+
const euid = getCookie('euid')
278+
if (euid) {
279+
span.setAttribute('user.euid', euid)
280+
}
281+
}
282+
283+
onEnd(): void {}
284+
285+
shutdown(): Promise<void> {
286+
return Promise.resolve()
287+
}
288+
289+
forceFlush(): Promise<void> {
290+
return Promise.resolve()
291+
}
292+
}
293+
294+
export interface OtelConfigOptions {
295+
serviceName?: string
296+
serviceVersion?: string
297+
baseUrl?: string
298+
debug?: boolean
299+
}
300+
301+
interface ResolvedConfig {
302+
serviceName: string
303+
serviceVersion: string
304+
baseUrl: string
305+
debug: boolean
306+
}

0 commit comments

Comments
 (0)