Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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