Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
<PackageVersion Include="Microsoft.OpenApi" Version="3.0.1" />
<PackageVersion Include="TUnit" Version="0.25.21" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="2.0.2" />
<PackageVersion Include="WireMock.Net" Version="1.6.11" />
</ItemGroup>
<!-- Build -->
<ItemGroup>
Expand Down
2 changes: 0 additions & 2 deletions src/Elastic.Documentation.Site/Assets/custom-elements.ts

This file was deleted.

5 changes: 0 additions & 5 deletions src/Elastic.Documentation.Site/Assets/image-carousel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,6 @@ class ImageCarousel {

this.prevButton.style.top = `${controlTop}px`
this.nextButton.style.top = `${controlTop}px`

// Debug logging (remove in production)
console.log(
`Carousel controls positioned: minHeight=${minHeight}px, controlTop=${controlTop}px`
)
}
}
}
Expand Down
20 changes: 20 additions & 0 deletions src/Elastic.Documentation.Site/Assets/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,33 @@ import { openDetailsWithAnchor } from './open-details-with-anchor'
import { initNav } from './pages-nav'
import { initSmoothScroll } from './smooth-scroll'
import { initTabs } from './tabs'
import { initializeOtel } from './telemetry/instrumentation'
import { initTocNav } from './toc-nav'
import 'htmx-ext-head-support'
import 'htmx-ext-preload'
import * as katex from 'katex'
import { $, $$ } from 'select-dom'
import { UAParser } from 'ua-parser-js'

// Injected at build time from MinVer
const DOCS_BUILDER_VERSION =
process.env.DOCS_BUILDER_VERSION?.trim() ?? '0.0.0-dev'

// Initialize OpenTelemetry FIRST, before any other code runs
// This must happen early so all subsequent code is instrumented
initializeOtel({
serviceName: 'docs-frontend',
serviceVersion: DOCS_BUILDER_VERSION,
baseUrl: '/docs',
debug: false,
})

// Dynamically import web components after telemetry is initialized
// This ensures telemetry is available when the components execute
// Parcel will automatically code-split this into a separate chunk
import('./web-components/SearchOrAskAi/SearchOrAskAi')
import('./web-components/VersionDropdown')

const { getOS } = new UAParser()
const isLazyLoadNavigationEnabled =
$('meta[property="docs:feature:lazy-load-navigation"]')?.content === 'true'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { UAParser } from 'ua-parser-js'

const { browser } = UAParser()
const parser = new UAParser()
const browser = parser.getBrowser()

// This is a fix for anchors in details elements in non-Chrome browsers.
export function openDetailsWithAnchor() {
Expand Down
306 changes: 306 additions & 0 deletions src/Elastic.Documentation.Site/Assets/telemetry/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
/**
* OpenTelemetry configuration for frontend telemetry.
* Sends traces and logs to the backend OTLP proxy endpoint.
*
* This module should be imported once at application startup.
* All web components will automatically be instrumented once initialized.
*
* Inspired by: https://signoz.io/docs/frontend-monitoring/sending-logs-with-opentelemetry/
*/
import { logs } from '@opentelemetry/api-logs'
import { ZoneContextManager } from '@opentelemetry/context-zone'
import { W3CTraceContextPropagator } from '@opentelemetry/core'
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { registerInstrumentations } from '@opentelemetry/instrumentation'
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch'
import { resourceFromAttributes } from '@opentelemetry/resources'
import {
LoggerProvider,
BatchLogRecordProcessor,
} from '@opentelemetry/sdk-logs'
import {
WebTracerProvider,
BatchSpanProcessor,
SpanProcessor,
Span,
} from '@opentelemetry/sdk-trace-web'
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions'

let isInitialized = false
let traceProvider: WebTracerProvider | null = null
let loggerProvider: LoggerProvider | null = null

export function initializeOtel(options: OtelConfigOptions = {}): boolean {
if (isAlreadyInitialized()) return false

markAsInitialized()

const config = resolveConfiguration(options)
logInitializationStart(config)

try {
const resource = createSharedResource(config)
const commonHeaders = createCommonHeaders()

initializeTracing(resource, config, commonHeaders)
initializeLogging(resource, config, commonHeaders)

setupAutoFlush(config.debug)
logInitializationSuccess(config)

return true
} catch (error) {
logInitializationError(error)
isInitialized = false
return false
}
}

function isAlreadyInitialized(): boolean {
if (isInitialized) {
console.warn(
'OpenTelemetry already initialized. Skipping re-initialization.'
)
return true
}
return false
}

function markAsInitialized(): void {
isInitialized = true
}

function resolveConfiguration(options: OtelConfigOptions): ResolvedConfig {
return {
serviceName: options.serviceName ?? 'docs-frontend',
serviceVersion: options.serviceVersion ?? '1.0.0',
baseUrl: options.baseUrl ?? window.location.origin,
debug: options.debug ?? false,
}
}

function logInitializationStart(config: ResolvedConfig): void {
if (config.debug) {
// eslint-disable-next-line no-console
console.log('[OTEL] Initializing OpenTelemetry with config:', config)
}
}

function createSharedResource(config: ResolvedConfig) {
const resourceAttributes: Record<string, string> = {
[ATTR_SERVICE_NAME]: config.serviceName,
[ATTR_SERVICE_VERSION]: config.serviceVersion,
}
return resourceFromAttributes(resourceAttributes)
}

function createCommonHeaders(): Record<string, string> {
return {
'X-Docs-Session': 'active',
}
}

function initializeTracing(
resource: ReturnType<typeof resourceFromAttributes>,
config: ResolvedConfig,
commonHeaders: Record<string, string>
): void {
const traceExporter = new OTLPTraceExporter({
url: `${config.baseUrl}/_api/v1/o/t`,
headers: { ...commonHeaders },
})

const spanProcessor = new BatchSpanProcessor(traceExporter)
const euidProcessor = new EuidSpanProcessor()

traceProvider = new WebTracerProvider({
resource,
spanProcessors: [euidProcessor, spanProcessor],
})

traceProvider.register({
contextManager: new ZoneContextManager(),
propagator: new W3CTraceContextPropagator(),
})

registerFetchInstrumentation()
}

function registerFetchInstrumentation(): void {
registerInstrumentations({
instrumentations: [
new FetchInstrumentation({
propagateTraceHeaderCorsUrls: [
new RegExp(`${window.location.origin}/.*`),
],
ignoreUrls: [
/_api\/v1\/o\/.*/,
/_api\/v1\/?$/,
/__parcel_code_frame$/,
],
applyCustomAttributesOnSpan: (span, request, result) => {
span.setAttribute('http.method', request.method || 'GET')
if (result instanceof Response) {
span.setAttribute('http.status_code', result.status)
}
},
}),
],
})
}

function initializeLogging(
resource: ReturnType<typeof resourceFromAttributes>,
config: ResolvedConfig,
commonHeaders: Record<string, string>
): void {
const logExporter = new OTLPLogExporter({
url: `${config.baseUrl}/_api/v1/o/l`,
headers: { ...commonHeaders },
})

const logProcessor = new BatchLogRecordProcessor(logExporter)

loggerProvider = new LoggerProvider({
resource,
processors: [logProcessor],
})

logs.setGlobalLoggerProvider(loggerProvider)
}

function setupAutoFlush(debug: boolean = false) {
let isFlushing = false

const performFlush = async () => {
if (isFlushing || !isInitialized) {
return
}

isFlushing = true

if (debug) {
// eslint-disable-next-line no-console
console.log(
'[OTEL] Auto-flushing telemetry (visibilitychange or pagehide)'
)
}

try {
await flushTelemetry()
} catch (error) {
if (debug) {
console.warn('[OTEL] Error during auto-flush:', error)
}
} finally {
isFlushing = false
}
}

document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
performFlush()
}
})

window.addEventListener('pagehide', performFlush)

if (debug) {
// eslint-disable-next-line no-console
console.log('[OTEL] Auto-flush event listeners registered')
// eslint-disable-next-line no-console
console.log(
'[OTEL] Using OTLP HTTP exporters with keepalive for guaranteed delivery'
)
}
}

async function flushTelemetry(timeoutMs: number = 1000): Promise<void> {
if (!isInitialized) {
return
}

const flushPromises: Promise<void>[] = []

if (traceProvider) {
flushPromises.push(
traceProvider.forceFlush().catch((err) => {
console.warn('[OTEL] Failed to flush traces:', err)
})
)
}

if (loggerProvider) {
flushPromises.push(
loggerProvider.forceFlush().catch((err) => {
console.warn('[OTEL] Failed to flush logs:', err)
})
)
}

await Promise.race([
Promise.all(flushPromises),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
])
}

function logInitializationSuccess(config: ResolvedConfig): void {
if (config.debug) {
// eslint-disable-next-line no-console
console.log('[OTEL] OpenTelemetry initialized successfully', {
serviceName: config.serviceName,
serviceVersion: config.serviceVersion,
traceEndpoint: `${config.baseUrl}/_api/v1/o/t`,
logEndpoint: `${config.baseUrl}/_api/v1/o/l`,
autoFlushOnUnload: true,
})
}
}

function logInitializationError(error: unknown): void {
console.error('[OTEL] Failed to initialize OpenTelemetry:', error)
}

function getCookie(name: string): string | null {
const value = `; ${document.cookie}`
const parts = value.split(`; ${name}=`)
if (parts.length === 2) return parts.pop()?.split(';').shift() || null
return null
}

class EuidSpanProcessor implements SpanProcessor {
onStart(span: Span): void {
const euid = getCookie('euid')
if (euid) {
span.setAttribute('user.euid', euid)
}
}

onEnd(): void {}

shutdown(): Promise<void> {
return Promise.resolve()
}

forceFlush(): Promise<void> {
return Promise.resolve()
}
}

export interface OtelConfigOptions {
serviceName?: string
serviceVersion?: string
baseUrl?: string
debug?: boolean
}

interface ResolvedConfig {
serviceName: string
serviceVersion: string
baseUrl: string
debug: boolean
}
Loading
Loading