From 33db2a1aa51c7f329b35b5e167250eb08ec494f5 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 20 Nov 2025 21:39:02 +0100 Subject: [PATCH 1/9] Add OTLP proxy endpoint --- Directory.Packages.props | 4 +- .../Elastic.Documentation.Api.Core.csproj | 1 + .../Telemetry/IOtlpGateway.cs | 25 ++ .../Telemetry/OtlpProxyOptions.cs | 58 ++++ .../Telemetry/OtlpProxyRequest.cs | 18 ++ .../Telemetry/OtlpProxyUsecase.cs | 46 +++ .../Telemetry/README.md | 276 ++++++++++++++++++ .../TelemetryConstants.cs | 6 + .../Adapters/Telemetry/AdotOtlpGateway.cs | 74 +++++ .../MappingsExtensions.cs | 41 +++ .../OpenTelemetry/OpenTelemetryExtensions.cs | 1 + .../ServicesExtension.cs | 19 ++ ....Documentation.Api.IntegrationTests.csproj | 1 + .../Examples/ServiceMockingExampleTests.cs | 95 ++++++ .../Fixtures/ApiWebApplicationFactory.cs | 187 +++++++++--- .../OtlpProxyIntegrationTests.cs | 198 +++++++++++++ 16 files changed, 1002 insertions(+), 48 deletions(-) create mode 100644 src/api/Elastic.Documentation.Api.Core/Telemetry/IOtlpGateway.cs create mode 100644 src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyOptions.cs create mode 100644 src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyRequest.cs create mode 100644 src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyUsecase.cs create mode 100644 src/api/Elastic.Documentation.Api.Core/Telemetry/README.md create mode 100644 src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Telemetry/AdotOtlpGateway.cs create mode 100644 tests-integration/Elastic.Documentation.Api.IntegrationTests/Examples/ServiceMockingExampleTests.cs create mode 100644 tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 8e69507a4..05320f0cd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,6 +27,7 @@ + @@ -41,6 +42,7 @@ + @@ -106,4 +108,4 @@ - \ No newline at end of file + diff --git a/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj b/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj index e6e497c1b..a1770fca2 100644 --- a/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj +++ b/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj @@ -9,6 +9,7 @@ + diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/IOtlpGateway.cs b/src/api/Elastic.Documentation.Api.Core/Telemetry/IOtlpGateway.cs new file mode 100644 index 000000000..9591bb53c --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/IOtlpGateway.cs @@ -0,0 +1,25 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.Api.Core.Telemetry; + +/// +/// Gateway for forwarding OTLP telemetry to a collector. +/// +public interface IOtlpGateway +{ + /// + /// Forwards OTLP telemetry data to the collector. + /// + /// The OTLP signal type: traces, logs, or metrics + /// The raw OTLP payload stream + /// Content-Type of the payload + /// Cancellation token + /// HTTP status code and response content + Task<(int StatusCode, string? Content)> ForwardOtlp( + string signalType, + Stream requestBody, + string contentType, + Cancel ctx = default); +} diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyOptions.cs b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyOptions.cs new file mode 100644 index 000000000..d976b72eb --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyOptions.cs @@ -0,0 +1,58 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Microsoft.Extensions.Configuration; + +namespace Elastic.Documentation.Api.Core.Telemetry; + +/// +/// Configuration options for the OTLP proxy. +/// When using ADOT Lambda Layer, the proxy forwards to the local collector at localhost:4318. +/// The ADOT layer handles authentication and forwarding to the backend (Elastic APM, etc). +/// +/// +/// ADOT Lambda Layer runs a local OpenTelemetry Collector that accepts OTLP/HTTP on: +/// - localhost:4318 (HTTP/JSON and HTTP/protobuf) +/// - localhost:4317 (gRPC) +/// +/// The ADOT layer is configured via environment variables: +/// - OTEL_EXPORTER_OTLP_ENDPOINT: Where ADOT forwards telemetry +/// - OTEL_EXPORTER_OTLP_HEADERS: Authentication headers ADOT uses +/// - AWS_LAMBDA_EXEC_WRAPPER: /opt/otel-instrument (enables ADOT) +/// +public class OtlpProxyOptions +{ + /// + /// OTLP endpoint URL for the local ADOT collector. + /// Defaults to localhost:4318 when running in Lambda with ADOT layer. + /// + public string Endpoint { get; } + + public OtlpProxyOptions(IConfiguration configuration) + { + // Check for test override first (for integration tests with WireMock) + var configEndpoint = configuration["OtlpProxy:Endpoint"]; + if (!string.IsNullOrEmpty(configEndpoint)) + { + Endpoint = configEndpoint; + return; + } + + // Check if we're in Lambda with ADOT layer + var execWrapper = Environment.GetEnvironmentVariable("AWS_LAMBDA_EXEC_WRAPPER"); + var isAdotEnabled = execWrapper?.Contains("otel-instrument") == true; + + if (isAdotEnabled) + { + // ADOT Lambda Layer runs collector on localhost:4318 + Endpoint = "http://localhost:4318"; + } + else + { + // Fallback to configured endpoint for local development + Endpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") + ?? "http://localhost:4318"; + } + } +} diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyRequest.cs b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyRequest.cs new file mode 100644 index 000000000..90d45c093 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyRequest.cs @@ -0,0 +1,18 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.Api.Core.Telemetry; + +/// +/// Request model for OTLP proxy endpoint. +/// Accepts raw OTLP payload from frontend and forwards to configured OTLP endpoint. +/// +public class OtlpProxyRequest +{ + /// + /// The OTLP signal type: traces, logs, or metrics + /// + public required string SignalType { get; init; } +} + diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyUsecase.cs b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyUsecase.cs new file mode 100644 index 000000000..4c8c8f2f7 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyUsecase.cs @@ -0,0 +1,46 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Elastic.Documentation.Api.Core.Telemetry; + +/// +/// Proxies OTLP telemetry from the frontend to the local ADOT Lambda Layer collector. +/// The ADOT layer handles authentication and forwarding to the backend. +/// +public class OtlpProxyUsecase( + IOtlpGateway gateway, + ILogger logger) +{ + private static readonly ActivitySource ActivitySource = new(TelemetryConstants.OtlpProxySourceName); + + /// + /// Proxies OTLP data from the frontend to the local ADOT collector. + /// + /// The OTLP signal type: traces, logs, or metrics + /// The raw OTLP payload (JSON or protobuf) + /// Content-Type header from the original request + /// Cancellation token + /// HTTP status code and response content + public async Task<(int StatusCode, string? Content)> ProxyOtlp( + string signalType, + Stream requestBody, + string contentType, + Cancel ctx = default) + { + using var activity = ActivitySource.StartActivity("ProxyOtlp", ActivityKind.Client); + + // Validate signal type + if (signalType is not ("traces" or "logs" or "metrics")) + { + logger.LogWarning("Invalid OTLP signal type: {SignalType}", signalType); + return (400, $"Invalid signal type: {signalType}. Must be traces, logs, or metrics"); + } + + // Forward to gateway + return await gateway.ForwardOtlp(signalType, requestBody, contentType, ctx); + } +} diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/README.md b/src/api/Elastic.Documentation.Api.Core/Telemetry/README.md new file mode 100644 index 000000000..980c3c6f4 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/README.md @@ -0,0 +1,276 @@ +# OTLP Proxy for Frontend Telemetry + +This OTLP (OpenTelemetry Protocol) proxy allows frontend JavaScript code to send telemetry (logs, traces, metrics) to the same OTLP collector used by the backend **without exposing authentication credentials to the browser**. + +## Security Model + +### ✅ Secure: Backend handles authentication + +``` +Frontend (Browser) → API Proxy → OTLP Collector (Elastic APM/OTel) + ↑ + Adds auth headers + (credentials stay secure) +``` + +The proxy: +- Reads credentials from environment variables on the backend +- Automatically adds authentication headers to forwarded requests +- Prevents credential exposure to browser DevTools or network inspection + +### ❌ Insecure: Direct frontend connection + +``` +Frontend (Browser) → OTLP Collector + ↑ + Requires auth credentials + (exposed in browser code) +``` + +## Configuration + +The proxy uses standard OpenTelemetry environment variables: + +```bash +# Required: OTLP collector endpoint +OTEL_EXPORTER_OTLP_ENDPOINT=https://your-apm-server.elastic.co:443 + +# Optional: Authentication headers (multiple headers separated by comma) +OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer secret-token" + +# Or for Elastic APM with API Key: +OTEL_EXPORTER_OTLP_HEADERS="Authorization=ApiKey base64-encoded-api-key" + +# Or multiple headers: +OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer token,X-Custom-Header=value" +``` + +## API Endpoints + +The proxy provides three endpoints matching the OTLP specification: + +``` +POST /docs/_api/v1/otlp/v1/traces - Forward trace spans +POST /docs/_api/v1/otlp/v1/logs - Forward log records +POST /docs/_api/v1/otlp/v1/metrics - Forward metrics +``` + +### Content Types Supported + +- `application/json` - OTLP JSON encoding (recommended for browser) +- `application/x-protobuf` - OTLP protobuf encoding (smaller but requires encoding) + +## Frontend Usage + +### Option 1: Using OpenTelemetry JS SDK (Recommended) + +```typescript +import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { Resource } from '@opentelemetry/resources'; +import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; + +// Configure the tracer to use the proxy endpoint +const provider = new WebTracerProvider({ + resource: new Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: 'docs-frontend', + [SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0', + }), +}); + +// Point the exporter to the proxy endpoint (no credentials needed!) +const exporter = new OTLPTraceExporter({ + url: 'https://docs.elastic.co/_api/v1/otlp/v1/traces', + headers: {}, // No auth headers needed - proxy handles it +}); + +provider.addSpanProcessor(new BatchSpanProcessor(exporter)); +provider.register(); + +// Now you can create spans +const tracer = provider.getTracer('docs-frontend'); +const span = tracer.startSpan('page-load'); +span.end(); +``` + +### Option 2: Using OpenTelemetry Logs API + +```typescript +import { LoggerProvider } from '@opentelemetry/sdk-logs'; +import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'; + +const loggerProvider = new LoggerProvider({ + resource: new Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: 'docs-frontend', + }), +}); + +const exporter = new OTLPLogExporter({ + url: 'https://docs.elastic.co/_api/v1/otlp/v1/logs', +}); + +loggerProvider.addLogRecordProcessor(new BatchLogRecordProcessor(exporter)); + +const logger = loggerProvider.getLogger('docs-frontend'); +logger.emit({ + severityNumber: 9, + severityText: 'INFO', + body: 'User clicked button', + attributes: { + 'user.action': 'click', + 'button.id': 'submit', + }, +}); +``` + +### Option 3: Manual Fetch (for debugging) + +```typescript +// Send logs manually +await fetch('https://docs.elastic.co/_api/v1/otlp/v1/logs', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + resourceLogs: [ + { + resource: { + attributes: [ + { key: 'service.name', value: { stringValue: 'docs-frontend' } }, + ], + }, + scopeLogs: [ + { + logRecords: [ + { + timeUnixNano: String(Date.now() * 1000000), + severityNumber: 9, + severityText: 'INFO', + body: { + stringValue: 'Test log from browser', + }, + attributes: [ + { key: 'page.url', value: { stringValue: window.location.href } }, + ], + }, + ], + }, + ], + }, + ], + }), +}); +``` + +## CORS Configuration + +If your frontend is served from a different domain, you'll need to configure CORS: + +```csharp +// In Program.cs or startup configuration +app.UseCors(policy => policy + .WithOrigins("https://docs.elastic.co") + .AllowAnyMethod() + .AllowAnyHeader()); +``` + +## Monitoring the Proxy + +The proxy creates its own spans under the `Elastic.Documentation.Api.OtlpProxy` activity source. + +Each proxied request includes: +- `otel.signal_type` - The signal type (traces/logs/metrics) +- `otel.content_type` - The content type of the request +- `otel.target_url` - The target OTLP collector URL +- `http.response.status_code` - Response status from collector + +## Example: Full Frontend Integration + +```typescript +// frontend/telemetry.ts +import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'; +import { ZoneContextManager } from '@opentelemetry/context-zone'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load'; +import { UserInteractionInstrumentation } from '@opentelemetry/instrumentation-user-interaction'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; + +export function initTelemetry() { + const provider = new WebTracerProvider({ + resource: new Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: 'docs-frontend', + [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: + window.location.hostname.includes('localhost') ? 'dev' : 'prod', + }), + }); + + // Use the proxy endpoint - no credentials needed! + const exporter = new OTLPTraceExporter({ + url: `${window.location.origin}/_api/v1/otlp/v1/traces`, + }); + + provider.addSpanProcessor(new BatchSpanProcessor(exporter, { + maxQueueSize: 100, + scheduledDelayMillis: 5000, + })); + + provider.register({ + contextManager: new ZoneContextManager(), + }); + + // Auto-instrument page loads and user interactions + registerInstrumentations({ + instrumentations: [ + new DocumentLoadInstrumentation(), + new UserInteractionInstrumentation(), + ], + }); + + console.log('OpenTelemetry initialized with proxy'); +} +``` + +## Troubleshooting + +### Proxy returns 503 "OTLP proxy is not configured" + +The `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable is not set. Configure it in your Lambda environment variables. + +### Proxy returns 401/403 from collector + +The authentication headers in `OTEL_EXPORTER_OTLP_HEADERS` are invalid or expired. + +### Frontend gets CORS errors + +Add CORS configuration to allow requests from your frontend domain. + +### Data not appearing in Elastic APM + +1. Check the proxy logs for errors +2. Verify the OTLP endpoint is correct +3. Ensure the collector is configured to accept OTLP/HTTP on `/v1/traces`, `/v1/logs`, `/v1/metrics` +4. Check that your OTLP payload format is correct (use the OpenTelemetry SDK to avoid formatting errors) + +## Performance Considerations + +- The proxy uses streaming to avoid buffering large payloads in memory +- Batch telemetry in the frontend before sending (use `BatchSpanProcessor` and `BatchLogRecordProcessor`) +- Consider sampling high-volume traces in production +- Monitor proxy latency via the `Elastic.Documentation.Api.OtlpProxy` spans + +## Security Best Practices + +✅ **DO:** +- Use HTTPS for the proxy endpoint in production +- Set appropriate rate limits on the proxy endpoint +- Monitor for unusual traffic patterns +- Use resource attributes to identify the frontend service + +❌ **DON'T:** +- Expose OTLP collector credentials in frontend code +- Allow unauthenticated access to the collector directly +- Send PII (personally identifiable information) in telemetry without user consent +- Forget to configure CORS for cross-origin requests + diff --git a/src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs b/src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs index b6a36c7c6..6a8d2683c 100644 --- a/src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs +++ b/src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs @@ -25,4 +25,10 @@ public static class TelemetryConstants /// Tag/baggage name used to annotate spans with the user's EUID value. /// public const string UserEuidAttributeName = "user.euid"; + + /// + /// ActivitySource name for OTLP proxy operations. + /// Used to trace frontend telemetry proxying. + /// + public const string OtlpProxySourceName = "Elastic.Documentation.Api.OtlpProxy"; } diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Telemetry/AdotOtlpGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Telemetry/AdotOtlpGateway.cs new file mode 100644 index 000000000..f045528ca --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Telemetry/AdotOtlpGateway.cs @@ -0,0 +1,74 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Documentation.Api.Core.Telemetry; +using Microsoft.Extensions.Logging; + +namespace Elastic.Documentation.Api.Infrastructure.Adapters.Telemetry; + +/// +/// Gateway that forwards OTLP telemetry to the ADOT Lambda Layer collector. +/// +public class AdotOtlpGateway( + OtlpProxyOptions options, + ILogger logger) : IOtlpGateway +{ + private static readonly HttpClient HttpClient = new() + { + Timeout = TimeSpan.FromSeconds(30) + }; + + /// + public async Task<(int StatusCode, string? Content)> ForwardOtlp( + string signalType, + Stream requestBody, + string contentType, + Cancel ctx = default) + { + try + { + // Build the target URL: http://localhost:4318/v1/{signalType} + var targetUrl = $"{options.Endpoint.TrimEnd('/')}/v1/{signalType}"; + + logger.LogDebug("Forwarding OTLP {SignalType} to ADOT collector at {TargetUrl}", signalType, targetUrl); + + using var request = new HttpRequestMessage(HttpMethod.Post, targetUrl); + + // Forward the content + request.Content = new StreamContent(requestBody); + request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType); + + // No need to add authentication headers - ADOT layer handles auth to backend + // Just forward the telemetry to the local collector + + // Forward to ADOT collector + using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, ctx); + var responseContent = response.Content.Headers.ContentLength > 0 + ? await response.Content.ReadAsStringAsync(ctx) + : string.Empty; + + if (!response.IsSuccessStatusCode) + { + logger.LogError("OTLP forward to ADOT failed with status {StatusCode}: {Content}", + response.StatusCode, responseContent); + } + else + { + logger.LogDebug("Successfully forwarded OTLP {SignalType} to ADOT collector", signalType); + } + + return ((int)response.StatusCode, responseContent); + } + catch (HttpRequestException ex) when (ex.Message.Contains("Connection refused") || ex.InnerException?.Message?.Contains("Connection refused") == true) + { + logger.LogError(ex, "Failed to connect to ADOT collector at {Endpoint}. Is ADOT Lambda Layer enabled?", options.Endpoint); + return (503, "ADOT collector not available. Ensure AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-instrument is set"); + } + catch (Exception ex) + { + logger.LogError(ex, "Error forwarding OTLP {SignalType}", signalType); + return (500, $"Error forwarding OTLP: {ex.Message}"); + } + } +} diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs b/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs index c95372a69..ce7724699 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs @@ -4,6 +4,7 @@ using Elastic.Documentation.Api.Core.AskAi; using Elastic.Documentation.Api.Core.Search; +using Elastic.Documentation.Api.Core.Telemetry; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -19,6 +20,7 @@ public static void MapElasticDocsApiEndpoints(this IEndpointRouteBuilder group) _ = group.MapPost("/", () => Results.Empty); MapAskAiEndpoint(group); MapSearchEndpoint(group); + MapOtlpProxyEndpoint(group); } private static void MapAskAiEndpoint(IEndpointRouteBuilder group) @@ -55,4 +57,43 @@ Cancel ctx return Results.Ok(searchResponse); }); } + + private static void MapOtlpProxyEndpoint(IEndpointRouteBuilder group) + { + // Use /o/* to avoid adblocker detection (common blocklists target /otlp, /telemetry, etc.) + var otlpGroup = group.MapGroup("/o"); + + // Proxy endpoint for traces + // Frontend: POST /_api/v1/o/t → ADOT: POST localhost:4318/v1/traces + _ = otlpGroup.MapPost("/t", + async (HttpContext context, OtlpProxyUsecase proxyUsecase, Cancel ctx) => + { + var contentType = context.Request.ContentType ?? "application/json"; + var (statusCode, content) = await proxyUsecase.ProxyOtlp("traces", context.Request.Body, contentType, ctx); + return Results.Content(content ?? string.Empty, contentType, statusCode: statusCode); + }) + .DisableAntiforgery(); // Frontend requests won't have antiforgery tokens + + // Proxy endpoint for logs + // Frontend: POST /_api/v1/o/l → ADOT: POST localhost:4318/v1/logs + _ = otlpGroup.MapPost("/l", + async (HttpContext context, OtlpProxyUsecase proxyUsecase, Cancel ctx) => + { + var contentType = context.Request.ContentType ?? "application/json"; + var (statusCode, content) = await proxyUsecase.ProxyOtlp("logs", context.Request.Body, contentType, ctx); + return Results.Content(content ?? string.Empty, contentType, statusCode: statusCode); + }) + .DisableAntiforgery(); + + // Proxy endpoint for metrics + // Frontend: POST /_api/v1/o/m → ADOT: POST localhost:4318/v1/metrics + _ = otlpGroup.MapPost("/m", + async (HttpContext context, OtlpProxyUsecase proxyUsecase, Cancel ctx) => + { + var contentType = context.Request.ContentType ?? "application/json"; + var (statusCode, content) = await proxyUsecase.ProxyOtlp("metrics", context.Request.Body, contentType, ctx); + return Results.Content(content ?? string.Empty, contentType, statusCode: statusCode); + }) + .DisableAntiforgery(); + } } diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs b/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs index 7927ab1a7..55ad64151 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs @@ -36,6 +36,7 @@ public static TracerProviderBuilder AddDocsApiTracing(this TracerProviderBuilder _ = builder .AddSource(TelemetryConstants.AskAiSourceName) .AddSource(TelemetryConstants.StreamTransformerSourceName) + .AddSource(TelemetryConstants.OtlpProxySourceName) .AddAspNetCoreInstrumentation(aspNetCoreOptions => { // Enrich spans with custom attributes from HTTP context diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs index 328bff119..416035992 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs @@ -6,10 +6,13 @@ using Elastic.Documentation.Api.Core; using Elastic.Documentation.Api.Core.AskAi; using Elastic.Documentation.Api.Core.Search; +using Elastic.Documentation.Api.Core.Telemetry; using Elastic.Documentation.Api.Infrastructure.Adapters.AskAi; using Elastic.Documentation.Api.Infrastructure.Adapters.Search; +using Elastic.Documentation.Api.Infrastructure.Adapters.Telemetry; using Elastic.Documentation.Api.Infrastructure.Aws; using Elastic.Documentation.Api.Infrastructure.Gcp; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NetEscapades.EnumGenerators; @@ -71,6 +74,7 @@ private static void AddElasticDocsApiUsecases(this IServiceCollection services, AddParameterProvider(services, appEnv); AddAskAiUsecase(services, appEnv); AddSearchUsecase(services, appEnv); + AddOtlpProxyUsecase(services, appEnv); } // https://docs.aws.amazon.com/systems -manager/latest/userguide/ps-integration-lambda-extensions.html @@ -171,4 +175,19 @@ private static void AddSearchUsecase(IServiceCollection services, AppEnv appEnv) _ = services.AddScoped(); _ = services.AddScoped(); } + + private static void AddOtlpProxyUsecase(IServiceCollection services, AppEnv appEnv) + { + var logger = GetLogger(services); + logger?.LogInformation("Configuring OTLP proxy use case for environment {AppEnvironment}", appEnv); + + _ = services.AddSingleton(sp => + { + var config = sp.GetRequiredService(); + return new OtlpProxyOptions(config); + }); + _ = services.AddScoped(); + _ = services.AddScoped(); + logger?.LogInformation("OTLP proxy configured to forward to ADOT Lambda Layer collector"); + } } diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj index 32e364eba..d319a74c5 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj @@ -14,6 +14,7 @@ + diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Examples/ServiceMockingExampleTests.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Examples/ServiceMockingExampleTests.cs new file mode 100644 index 000000000..e27fd61d3 --- /dev/null +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Examples/ServiceMockingExampleTests.cs @@ -0,0 +1,95 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Net; +using Elastic.Documentation.Api.Core.Search; +using Elastic.Documentation.Api.Core.Telemetry; +using Elastic.Documentation.Api.IntegrationTests.Fixtures; +using FakeItEasy; +using FluentAssertions; +using Xunit; + +namespace Elastic.Documentation.Api.IntegrationTests.Examples; + +/// +/// Example test demonstrating how to mock multiple services in integration tests. +/// This serves as documentation for the service mocking pattern. +/// +public class ServiceMockingExampleTests +{ + [Fact] + public async Task ExampleWithMultipleServiceMocks() + { + // Arrange - Create multiple mocks + var mockOtlpGateway = A.Fake(); + var mockSearchGateway = A.Fake(); + + // Configure mock behaviors + A.CallTo(() => mockOtlpGateway.ForwardOtlp( + A._, + A._, + A._, + A._)) + .Returns(Task.FromResult<(int StatusCode, string? Content)>((200, "{}}"))); + + A.CallTo(() => mockSearchGateway.SearchAsync( + A._, + A._, + A._, + A._)) + .Returns(Task.FromResult((TotalHits: 1, Results: new List + { + new() + { + Type = "page", + Url = "/docs/test", + Title = "Test Result", + Description = "A test result", + Parents = [] + } + }))); + + // Create factory with multiple mocked services using fluent API + using var factory = ApiWebApplicationFactory.WithMockedServices(services => + services + .Replace(mockOtlpGateway) // Replace IOtlpGateway + .Replace(mockSearchGateway)); // Replace ISearchGateway + + using var client = factory.CreateClient(); + + // Act - Make a search request that uses the mocked search gateway + var searchResponse = await client.GetAsync( + "/docs/_api/v1/search?q=test&page=1&pageSize=5", + TestContext.Current.CancellationToken); + + // Assert + searchResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + // Verify the search gateway was called with correct parameters + A.CallTo(() => mockSearchGateway.SearchAsync( + "test", // query + 1, // page + 5, // pageSize (default in API) + A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExampleWithSingletonMock() + { + // Arrange + var mockGateway = A.Fake(); + A.CallTo(() => mockGateway.ForwardOtlp(A._, A._, A._, A._)) + .Returns(Task.FromResult<(int, string?)>((503, "Unavailable"))); + + // Use ReplaceSingleton for services registered as singletons + using var factory = ApiWebApplicationFactory.WithMockedServices(services => + services.ReplaceSingleton(mockGateway)); + + using var client = factory.CreateClient(); + + // Act & Assert + // Your test logic here... + } +} diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs index 96c60e7aa..3e3139515 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Text; using Elastic.Documentation.Api.Core.AskAi; +using Elastic.Documentation.Api.Core.Telemetry; using Elastic.Documentation.Api.Infrastructure; using Elastic.Documentation.Api.Infrastructure.Aws; using Elastic.Documentation.Api.Infrastructure.OpenTelemetry; @@ -12,6 +13,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using OpenTelemetry; using OpenTelemetry.Logs; @@ -28,53 +30,76 @@ public class ApiWebApplicationFactory : WebApplicationFactory public List ExportedActivities { get; } = []; public List ExportedLogRecords { get; } = []; private readonly List _mockMemoryStreams = []; - protected override void ConfigureWebHost(IWebHostBuilder builder) => - builder.ConfigureServices(services => - { - var otelBuilder = services.AddOpenTelemetry(); - _ = otelBuilder.WithTracing(tracing => - { - _ = tracing - .AddDocsApiTracing() // Reuses production configuration - .AddInMemoryExporter(ExportedActivities); - }); - _ = otelBuilder.WithLogging(logging => - { - _ = logging - .AddDocsApiLogging() // Reuses production configuration - .AddInMemoryExporter(ExportedLogRecords); - }); - - // Mock IParameterProvider to avoid AWS dependencies - var mockParameterProvider = A.Fake(); - A.CallTo(() => mockParameterProvider.GetParam(A._, A._, A._)) - .Returns(Task.FromResult("mock-value")); - _ = services.AddSingleton(mockParameterProvider); - - // Mock IAskAiGateway to avoid external AI service calls - var mockAskAiGateway = A.Fake>(); - A.CallTo(() => mockAskAiGateway.AskAi(A._, A._)) - .ReturnsLazily(() => - { - var stream = new MemoryStream(Encoding.UTF8.GetBytes("data: test\n\n")); - _mockMemoryStreams.Add(stream); - return Task.FromResult(stream); - }); - _ = services.AddSingleton(mockAskAiGateway); - - // Mock IStreamTransformer - var mockTransformer = A.Fake(); - A.CallTo(() => mockTransformer.AgentProvider).Returns("test-provider"); - A.CallTo(() => mockTransformer.AgentId).Returns("test-agent"); - A.CallTo(() => mockTransformer.TransformAsync(A._, A._, A._, A._)) - .ReturnsLazily((Stream s, string? _, Activity? activity, Cancel _) => - { - // Dispose the activity if provided (simulating what the real transformer does) - activity?.Dispose(); - return Task.FromResult(s); - }); - _ = services.AddSingleton(mockTransformer); - }); + private readonly Action? _configureServices; + + public ApiWebApplicationFactory() : this(null) + { + } + + internal ApiWebApplicationFactory(Action? configureServices) => _configureServices = configureServices; + + /// + /// Creates a factory with specific services replaced by mocks. + /// This allows tests to inject fake implementations for testing specific scenarios. + /// + /// Action to configure service replacements + /// New factory instance with replaced services + public static ApiWebApplicationFactory WithMockedServices(Action serviceReplacements) + { + var builder = new ServiceReplacementBuilder(); + serviceReplacements(builder); + return new ApiWebApplicationFactory(builder.Build()); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) => builder.ConfigureServices(services => + { + var otelBuilder = services.AddOpenTelemetry(); + _ = otelBuilder.WithTracing(tracing => + { + _ = tracing + .AddDocsApiTracing() // Reuses production configuration + .AddInMemoryExporter(ExportedActivities); + }); + _ = otelBuilder.WithLogging(logging => + { + _ = logging + .AddDocsApiLogging() // Reuses production configuration + .AddInMemoryExporter(ExportedLogRecords); + }); + + // Mock IParameterProvider to avoid AWS dependencies + var mockParameterProvider = A.Fake(); + A.CallTo(() => mockParameterProvider.GetParam(A._, A._, A._)) + .Returns(Task.FromResult("mock-value")); + _ = services.AddSingleton(mockParameterProvider); + + // Mock IAskAiGateway to avoid external AI service calls + var mockAskAiGateway = A.Fake>(); + A.CallTo(() => mockAskAiGateway.AskAi(A._, A._)) + .ReturnsLazily(() => + { + var stream = new MemoryStream(Encoding.UTF8.GetBytes("data: test\n\n")); + _mockMemoryStreams.Add(stream); + return Task.FromResult(stream); + }); + _ = services.AddSingleton(mockAskAiGateway); + + // Mock IStreamTransformer + var mockTransformer = A.Fake(); + A.CallTo(() => mockTransformer.AgentProvider).Returns("test-provider"); + A.CallTo(() => mockTransformer.AgentId).Returns("test-agent"); + A.CallTo(() => mockTransformer.TransformAsync(A._, A._, A._, A._)) + .ReturnsLazily((Stream s, string? _, Activity? activity, Cancel _) => + { + // Dispose the activity if provided (simulating what the real transformer does) + activity?.Dispose(); + return Task.FromResult(s); + }); + _ = services.AddSingleton(mockTransformer); + + // Allow tests to override services - RemoveAll + Add to properly replace + _configureServices?.Invoke(services); + }); protected override void Dispose(bool disposing) { @@ -89,3 +114,71 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } } + +/// +/// Builder for replacing services in integration tests. +/// Provides a fluent API for replacing multiple services with mocks. +/// +public class ServiceReplacementBuilder +{ + private readonly List> _replacements = []; + + /// + /// Replace a service of type TService with a specific instance. + /// + /// The service interface type to replace + /// The mock/fake instance to use + /// This builder for chaining + public ServiceReplacementBuilder Replace(TService instance) where TService : class + { + _replacements.Add(services => + { + services.RemoveAll(); + _ = services.AddScoped(_ => instance); + }); + return this; + } + + /// + /// Replace a service of type TService with a factory function. + /// + /// The service interface type to replace + /// Factory function to create the service + /// This builder for chaining + public ServiceReplacementBuilder Replace(Func factory) where TService : class + { + _replacements.Add(services => + { + services.RemoveAll(); + _ = services.AddScoped(factory); + }); + return this; + } + + /// + /// Replace a service with a singleton instance. + /// + /// The service interface type to replace + /// The singleton instance to use + /// This builder for chaining + public ServiceReplacementBuilder ReplaceSingleton(TService instance) where TService : class + { + _replacements.Add(services => + { + services.RemoveAll(); + _ = services.AddSingleton(_ => instance); + }); + return this; + } + + /// + /// Builds the final service configuration action. + /// + internal Action Build() => services => + { + foreach (var replacement in _replacements) + { + replacement(services); + } + }; +} diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs new file mode 100644 index 000000000..ed31c714d --- /dev/null +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs @@ -0,0 +1,198 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Net; +using System.Text; +using Elastic.Documentation.Api.Core.Telemetry; +using Elastic.Documentation.Api.IntegrationTests.Fixtures; +using FakeItEasy; +using FluentAssertions; +using Xunit; + +namespace Elastic.Documentation.Api.IntegrationTests; + +public class OtlpProxyIntegrationTests +{ + [Fact] + public async Task OtlpProxyTracesEndpointReturnsSuccess() + { + // Arrange + var mockGateway = A.Fake(); + A.CallTo(() => mockGateway.ForwardOtlp( + A._, + A._, + A._, + A._)) + .Returns(Task.FromResult<(int StatusCode, string? Content)>((200, "{}"))); + + using var factory = ApiWebApplicationFactory.WithMockedServices(services => + services.Replace(mockGateway)); + using var client = factory.CreateClient(); + + var otlpPayload = /*lang=json,strict*/ """ + { + "resourceSpans": [{ + "scopeSpans": [{ + "spans": [{ + "traceId": "0123456789abcdef0123456789abcdef", + "spanId": "0123456789abcdef", + "name": "test-span" + }] + }] + }] + } + """; + var content = new StringContent(otlpPayload, Encoding.UTF8, "application/json"); + + // Act + var response = await client.PostAsync("/docs/_api/v1/o/t", content, TestContext.Current.CancellationToken); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + // Verify gateway was called + A.CallTo(() => mockGateway.ForwardOtlp( + "traces", + A._, + A._, + A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task OtlpProxyLogsEndpointReturnsSuccess() + { + // Arrange + var mockGateway = A.Fake(); + A.CallTo(() => mockGateway.ForwardOtlp( + A._, + A._, + A._, + A._)) + .Returns(Task.FromResult<(int StatusCode, string? Content)>((200, "{}"))); + + using var factory = ApiWebApplicationFactory.WithMockedServices(services => + services.Replace(mockGateway)); + using var client = factory.CreateClient(); + + var otlpPayload = /*lang=json,strict*/ """ + { + "resourceLogs": [{ + "scopeLogs": [{ + "logRecords": [{ + "timeUnixNano": "1672531200000000000", + "severityNumber": 9, + "severityText": "INFO", + "body": { + "stringValue": "Test log" + } + }] + }] + }] + } + """; + var content = new StringContent(otlpPayload, Encoding.UTF8, "application/json"); + + // Act + var response = await client.PostAsync("/docs/_api/v1/o/l", content, TestContext.Current.CancellationToken); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + // Verify gateway was called + A.CallTo(() => mockGateway.ForwardOtlp( + "logs", + A._, + A._, + A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task OtlpProxyMetricsEndpointReturnsSuccess() + { + // Arrange + var mockGateway = A.Fake(); + A.CallTo(() => mockGateway.ForwardOtlp( + A._, + A._, + A._, + A._)) + .Returns(Task.FromResult<(int StatusCode, string? Content)>((200, "{}"))); + + using var factory = ApiWebApplicationFactory.WithMockedServices(services => + services.Replace(mockGateway)); + using var client = factory.CreateClient(); + + var otlpPayload = /*lang=json,strict*/ """ + { + "resourceMetrics": [{ + "scopeMetrics": [{ + "metrics": [{ + "name": "test_metric", + "unit": "1" + }] + }] + }] + } + """; + var content = new StringContent(otlpPayload, Encoding.UTF8, "application/json"); + + // Act + var response = await client.PostAsync("/docs/_api/v1/o/m", content, TestContext.Current.CancellationToken); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + // Verify gateway was called + A.CallTo(() => mockGateway.ForwardOtlp( + "metrics", + A._, + A._, + A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task OtlpProxyReturnsGatewayErrorStatusCode() + { + // Arrange + var mockGateway = A.Fake(); + A.CallTo(() => mockGateway.ForwardOtlp( + A._, + A._, + A._, + A._)) + .Returns(Task.FromResult<(int StatusCode, string? Content)>((503, "Service unavailable"))); + + using var factory = ApiWebApplicationFactory.WithMockedServices(services => + services.Replace(mockGateway)); + using var client = factory.CreateClient(); + + var content = new StringContent("{}", Encoding.UTF8, "application/json"); + + // Act + var response = await client.PostAsync("/docs/_api/v1/o/t", content, TestContext.Current.CancellationToken); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + var responseBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + responseBody.Should().Contain("Service unavailable"); + } + + [Fact] + public async Task OtlpProxyInvalidSignalTypeReturns404() + { + // Arrange + using var factory = new ApiWebApplicationFactory(); + using var client = factory.CreateClient(); + var content = new StringContent("{}", Encoding.UTF8, "application/json"); + + // Act - use invalid signal type + var response = await client.PostAsync("/docs/_api/v1/o/invalid", content, TestContext.Current.CancellationToken); + + // Assert - route doesn't exist + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} From a8c7d98f23830542e2e506441ee4e94964516189 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 20 Nov 2025 21:55:26 +0100 Subject: [PATCH 2/9] Fix OtlpProxyOptions --- .../Telemetry/OtlpProxyOptions.cs | 34 +-- .../Telemetry/README.md | 276 ------------------ 2 files changed, 12 insertions(+), 298 deletions(-) delete mode 100644 src/api/Elastic.Documentation.Api.Core/Telemetry/README.md diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyOptions.cs b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyOptions.cs index d976b72eb..4872cb9b8 100644 --- a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyOptions.cs +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyOptions.cs @@ -8,18 +8,19 @@ namespace Elastic.Documentation.Api.Core.Telemetry; /// /// Configuration options for the OTLP proxy. -/// When using ADOT Lambda Layer, the proxy forwards to the local collector at localhost:4318. -/// The ADOT layer handles authentication and forwarding to the backend (Elastic APM, etc). +/// The proxy forwards telemetry to a local OTLP collector (typically ADOT Lambda Layer). /// /// /// ADOT Lambda Layer runs a local OpenTelemetry Collector that accepts OTLP/HTTP on: /// - localhost:4318 (HTTP/JSON and HTTP/protobuf) /// - localhost:4317 (gRPC) /// -/// The ADOT layer is configured via environment variables: -/// - OTEL_EXPORTER_OTLP_ENDPOINT: Where ADOT forwards telemetry -/// - OTEL_EXPORTER_OTLP_HEADERS: Authentication headers ADOT uses -/// - AWS_LAMBDA_EXEC_WRAPPER: /opt/otel-instrument (enables ADOT) +/// Configuration priority: +/// 1. OtlpProxy:Endpoint in IConfiguration (for tests/overrides) +/// 2. OTEL_EXPORTER_OTLP_ENDPOINT environment variable +/// 3. Default: http://localhost:4318 +/// +/// The proxy will return 503 if the collector is not available. /// public class OtlpProxyOptions { @@ -31,7 +32,7 @@ public class OtlpProxyOptions public OtlpProxyOptions(IConfiguration configuration) { - // Check for test override first (for integration tests with WireMock) + // Check for explicit configuration override first (for tests or custom deployments) var configEndpoint = configuration["OtlpProxy:Endpoint"]; if (!string.IsNullOrEmpty(configEndpoint)) { @@ -39,20 +40,9 @@ public OtlpProxyOptions(IConfiguration configuration) return; } - // Check if we're in Lambda with ADOT layer - var execWrapper = Environment.GetEnvironmentVariable("AWS_LAMBDA_EXEC_WRAPPER"); - var isAdotEnabled = execWrapper?.Contains("otel-instrument") == true; - - if (isAdotEnabled) - { - // ADOT Lambda Layer runs collector on localhost:4318 - Endpoint = "http://localhost:4318"; - } - else - { - // Fallback to configured endpoint for local development - Endpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") - ?? "http://localhost:4318"; - } + // Default to localhost:4318 - this is where ADOT Lambda Layer collector runs + // If ADOT layer is not present, the proxy will fail gracefully and return 503 + Endpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") + ?? "http://localhost:4318"; } } diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/README.md b/src/api/Elastic.Documentation.Api.Core/Telemetry/README.md deleted file mode 100644 index 980c3c6f4..000000000 --- a/src/api/Elastic.Documentation.Api.Core/Telemetry/README.md +++ /dev/null @@ -1,276 +0,0 @@ -# OTLP Proxy for Frontend Telemetry - -This OTLP (OpenTelemetry Protocol) proxy allows frontend JavaScript code to send telemetry (logs, traces, metrics) to the same OTLP collector used by the backend **without exposing authentication credentials to the browser**. - -## Security Model - -### ✅ Secure: Backend handles authentication - -``` -Frontend (Browser) → API Proxy → OTLP Collector (Elastic APM/OTel) - ↑ - Adds auth headers - (credentials stay secure) -``` - -The proxy: -- Reads credentials from environment variables on the backend -- Automatically adds authentication headers to forwarded requests -- Prevents credential exposure to browser DevTools or network inspection - -### ❌ Insecure: Direct frontend connection - -``` -Frontend (Browser) → OTLP Collector - ↑ - Requires auth credentials - (exposed in browser code) -``` - -## Configuration - -The proxy uses standard OpenTelemetry environment variables: - -```bash -# Required: OTLP collector endpoint -OTEL_EXPORTER_OTLP_ENDPOINT=https://your-apm-server.elastic.co:443 - -# Optional: Authentication headers (multiple headers separated by comma) -OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer secret-token" - -# Or for Elastic APM with API Key: -OTEL_EXPORTER_OTLP_HEADERS="Authorization=ApiKey base64-encoded-api-key" - -# Or multiple headers: -OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer token,X-Custom-Header=value" -``` - -## API Endpoints - -The proxy provides three endpoints matching the OTLP specification: - -``` -POST /docs/_api/v1/otlp/v1/traces - Forward trace spans -POST /docs/_api/v1/otlp/v1/logs - Forward log records -POST /docs/_api/v1/otlp/v1/metrics - Forward metrics -``` - -### Content Types Supported - -- `application/json` - OTLP JSON encoding (recommended for browser) -- `application/x-protobuf` - OTLP protobuf encoding (smaller but requires encoding) - -## Frontend Usage - -### Option 1: Using OpenTelemetry JS SDK (Recommended) - -```typescript -import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'; -import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; -import { Resource } from '@opentelemetry/resources'; -import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; - -// Configure the tracer to use the proxy endpoint -const provider = new WebTracerProvider({ - resource: new Resource({ - [SemanticResourceAttributes.SERVICE_NAME]: 'docs-frontend', - [SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0', - }), -}); - -// Point the exporter to the proxy endpoint (no credentials needed!) -const exporter = new OTLPTraceExporter({ - url: 'https://docs.elastic.co/_api/v1/otlp/v1/traces', - headers: {}, // No auth headers needed - proxy handles it -}); - -provider.addSpanProcessor(new BatchSpanProcessor(exporter)); -provider.register(); - -// Now you can create spans -const tracer = provider.getTracer('docs-frontend'); -const span = tracer.startSpan('page-load'); -span.end(); -``` - -### Option 2: Using OpenTelemetry Logs API - -```typescript -import { LoggerProvider } from '@opentelemetry/sdk-logs'; -import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'; - -const loggerProvider = new LoggerProvider({ - resource: new Resource({ - [SemanticResourceAttributes.SERVICE_NAME]: 'docs-frontend', - }), -}); - -const exporter = new OTLPLogExporter({ - url: 'https://docs.elastic.co/_api/v1/otlp/v1/logs', -}); - -loggerProvider.addLogRecordProcessor(new BatchLogRecordProcessor(exporter)); - -const logger = loggerProvider.getLogger('docs-frontend'); -logger.emit({ - severityNumber: 9, - severityText: 'INFO', - body: 'User clicked button', - attributes: { - 'user.action': 'click', - 'button.id': 'submit', - }, -}); -``` - -### Option 3: Manual Fetch (for debugging) - -```typescript -// Send logs manually -await fetch('https://docs.elastic.co/_api/v1/otlp/v1/logs', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - resourceLogs: [ - { - resource: { - attributes: [ - { key: 'service.name', value: { stringValue: 'docs-frontend' } }, - ], - }, - scopeLogs: [ - { - logRecords: [ - { - timeUnixNano: String(Date.now() * 1000000), - severityNumber: 9, - severityText: 'INFO', - body: { - stringValue: 'Test log from browser', - }, - attributes: [ - { key: 'page.url', value: { stringValue: window.location.href } }, - ], - }, - ], - }, - ], - }, - ], - }), -}); -``` - -## CORS Configuration - -If your frontend is served from a different domain, you'll need to configure CORS: - -```csharp -// In Program.cs or startup configuration -app.UseCors(policy => policy - .WithOrigins("https://docs.elastic.co") - .AllowAnyMethod() - .AllowAnyHeader()); -``` - -## Monitoring the Proxy - -The proxy creates its own spans under the `Elastic.Documentation.Api.OtlpProxy` activity source. - -Each proxied request includes: -- `otel.signal_type` - The signal type (traces/logs/metrics) -- `otel.content_type` - The content type of the request -- `otel.target_url` - The target OTLP collector URL -- `http.response.status_code` - Response status from collector - -## Example: Full Frontend Integration - -```typescript -// frontend/telemetry.ts -import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'; -import { ZoneContextManager } from '@opentelemetry/context-zone'; -import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; -import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load'; -import { UserInteractionInstrumentation } from '@opentelemetry/instrumentation-user-interaction'; -import { registerInstrumentations } from '@opentelemetry/instrumentation'; - -export function initTelemetry() { - const provider = new WebTracerProvider({ - resource: new Resource({ - [SemanticResourceAttributes.SERVICE_NAME]: 'docs-frontend', - [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: - window.location.hostname.includes('localhost') ? 'dev' : 'prod', - }), - }); - - // Use the proxy endpoint - no credentials needed! - const exporter = new OTLPTraceExporter({ - url: `${window.location.origin}/_api/v1/otlp/v1/traces`, - }); - - provider.addSpanProcessor(new BatchSpanProcessor(exporter, { - maxQueueSize: 100, - scheduledDelayMillis: 5000, - })); - - provider.register({ - contextManager: new ZoneContextManager(), - }); - - // Auto-instrument page loads and user interactions - registerInstrumentations({ - instrumentations: [ - new DocumentLoadInstrumentation(), - new UserInteractionInstrumentation(), - ], - }); - - console.log('OpenTelemetry initialized with proxy'); -} -``` - -## Troubleshooting - -### Proxy returns 503 "OTLP proxy is not configured" - -The `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable is not set. Configure it in your Lambda environment variables. - -### Proxy returns 401/403 from collector - -The authentication headers in `OTEL_EXPORTER_OTLP_HEADERS` are invalid or expired. - -### Frontend gets CORS errors - -Add CORS configuration to allow requests from your frontend domain. - -### Data not appearing in Elastic APM - -1. Check the proxy logs for errors -2. Verify the OTLP endpoint is correct -3. Ensure the collector is configured to accept OTLP/HTTP on `/v1/traces`, `/v1/logs`, `/v1/metrics` -4. Check that your OTLP payload format is correct (use the OpenTelemetry SDK to avoid formatting errors) - -## Performance Considerations - -- The proxy uses streaming to avoid buffering large payloads in memory -- Batch telemetry in the frontend before sending (use `BatchSpanProcessor` and `BatchLogRecordProcessor`) -- Consider sampling high-volume traces in production -- Monitor proxy latency via the `Elastic.Documentation.Api.OtlpProxy` spans - -## Security Best Practices - -✅ **DO:** -- Use HTTPS for the proxy endpoint in production -- Set appropriate rate limits on the proxy endpoint -- Monitor for unusual traffic patterns -- Use resource attributes to identify the frontend service - -❌ **DON'T:** -- Expose OTLP collector credentials in frontend code -- Allow unauthenticated access to the collector directly -- Send PII (personally identifiable information) in telemetry without user consent -- Forget to configure CORS for cross-origin requests - From 1abb9bd13d9de2fdefa875309f2457c3d06a22b9 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 20 Nov 2025 22:31:30 +0100 Subject: [PATCH 3/9] Refactor --- .../Elastic.Documentation.Api.Core.csproj | 1 + .../Telemetry/IOtlpGateway.cs | 4 +- .../Telemetry/OtlpProxyRequest.cs | 30 +++- .../Telemetry/OtlpProxyUsecase.cs | 16 +- .../Adapters/Telemetry/AdotOtlpGateway.cs | 18 +-- .../MappingsExtensions.cs | 6 +- .../ServicesExtension.cs | 8 + .../Examples/ServiceMockingExampleTests.cs | 4 +- .../Fixtures/ApiWebApplicationFactory.cs | 8 + .../OtlpProxyIntegrationTests.cs | 149 ++++++++++-------- 10 files changed, 145 insertions(+), 99 deletions(-) diff --git a/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj b/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj index a1770fca2..366861512 100644 --- a/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj +++ b/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj @@ -12,6 +12,7 @@ + diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/IOtlpGateway.cs b/src/api/Elastic.Documentation.Api.Core/Telemetry/IOtlpGateway.cs index 9591bb53c..ae2518af2 100644 --- a/src/api/Elastic.Documentation.Api.Core/Telemetry/IOtlpGateway.cs +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/IOtlpGateway.cs @@ -12,13 +12,13 @@ public interface IOtlpGateway /// /// Forwards OTLP telemetry data to the collector. /// - /// The OTLP signal type: traces, logs, or metrics + /// The OTLP signal type (traces, logs, or metrics) /// The raw OTLP payload stream /// Content-Type of the payload /// Cancellation token /// HTTP status code and response content Task<(int StatusCode, string? Content)> ForwardOtlp( - string signalType, + OtlpSignalType signalType, Stream requestBody, string contentType, Cancel ctx = default); diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyRequest.cs b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyRequest.cs index 90d45c093..6b79762cc 100644 --- a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyRequest.cs +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyRequest.cs @@ -2,8 +2,37 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.ComponentModel.DataAnnotations; +using NetEscapades.EnumGenerators; + namespace Elastic.Documentation.Api.Core.Telemetry; +/// +/// OTLP signal types supported by the proxy. +/// The Display names match the OTLP path segments (lowercase). +/// +[EnumExtensions] +public enum OtlpSignalType +{ + /// + /// Distributed traces - maps to /v1/traces + /// + [Display(Name = "traces")] + Traces, + + /// + /// Log records - maps to /v1/logs + /// + [Display(Name = "logs")] + Logs, + + /// + /// Metrics data - maps to /v1/metrics + /// + [Display(Name = "metrics")] + Metrics +} + /// /// Request model for OTLP proxy endpoint. /// Accepts raw OTLP payload from frontend and forwards to configured OTLP endpoint. @@ -15,4 +44,3 @@ public class OtlpProxyRequest /// public required string SignalType { get; init; } } - diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyUsecase.cs b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyUsecase.cs index 4c8c8f2f7..a0eb1fb9f 100644 --- a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyUsecase.cs +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyUsecase.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information using System.Diagnostics; -using Microsoft.Extensions.Logging; namespace Elastic.Documentation.Api.Core.Telemetry; @@ -11,35 +10,26 @@ namespace Elastic.Documentation.Api.Core.Telemetry; /// Proxies OTLP telemetry from the frontend to the local ADOT Lambda Layer collector. /// The ADOT layer handles authentication and forwarding to the backend. /// -public class OtlpProxyUsecase( - IOtlpGateway gateway, - ILogger logger) +public class OtlpProxyUsecase(IOtlpGateway gateway) { private static readonly ActivitySource ActivitySource = new(TelemetryConstants.OtlpProxySourceName); /// /// Proxies OTLP data from the frontend to the local ADOT collector. /// - /// The OTLP signal type: traces, logs, or metrics + /// The OTLP signal type (traces, logs, or metrics) /// The raw OTLP payload (JSON or protobuf) /// Content-Type header from the original request /// Cancellation token /// HTTP status code and response content public async Task<(int StatusCode, string? Content)> ProxyOtlp( - string signalType, + OtlpSignalType signalType, Stream requestBody, string contentType, Cancel ctx = default) { using var activity = ActivitySource.StartActivity("ProxyOtlp", ActivityKind.Client); - // Validate signal type - if (signalType is not ("traces" or "logs" or "metrics")) - { - logger.LogWarning("Invalid OTLP signal type: {SignalType}", signalType); - return (400, $"Invalid signal type: {signalType}. Must be traces, logs, or metrics"); - } - // Forward to gateway return await gateway.ForwardOtlp(signalType, requestBody, contentType, ctx); } diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Telemetry/AdotOtlpGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Telemetry/AdotOtlpGateway.cs index f045528ca..600af78e5 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Telemetry/AdotOtlpGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Telemetry/AdotOtlpGateway.cs @@ -11,17 +11,16 @@ namespace Elastic.Documentation.Api.Infrastructure.Adapters.Telemetry; /// Gateway that forwards OTLP telemetry to the ADOT Lambda Layer collector. /// public class AdotOtlpGateway( + IHttpClientFactory httpClientFactory, OtlpProxyOptions options, ILogger logger) : IOtlpGateway { - private static readonly HttpClient HttpClient = new() - { - Timeout = TimeSpan.FromSeconds(30) - }; + public const string HttpClientName = "OtlpProxy"; + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); /// public async Task<(int StatusCode, string? Content)> ForwardOtlp( - string signalType, + OtlpSignalType signalType, Stream requestBody, string contentType, Cancel ctx = default) @@ -29,21 +28,22 @@ public class AdotOtlpGateway( try { // Build the target URL: http://localhost:4318/v1/{signalType} - var targetUrl = $"{options.Endpoint.TrimEnd('/')}/v1/{signalType}"; + // Use ToStringFast(true) from generated enum extensions (returns Display name: "traces", "logs", "metrics") + var targetUrl = $"{options.Endpoint.TrimEnd('/')}/v1/{signalType.ToStringFast(true)}"; logger.LogDebug("Forwarding OTLP {SignalType} to ADOT collector at {TargetUrl}", signalType, targetUrl); using var request = new HttpRequestMessage(HttpMethod.Post, targetUrl); - // Forward the content + // Forward the content with the original content type request.Content = new StreamContent(requestBody); - request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType); + _ = request.Content.Headers.TryAddWithoutValidation("Content-Type", contentType); // No need to add authentication headers - ADOT layer handles auth to backend // Just forward the telemetry to the local collector // Forward to ADOT collector - using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, ctx); + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, ctx); var responseContent = response.Content.Headers.ContentLength > 0 ? await response.Content.ReadAsStringAsync(ctx) : string.Empty; diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs b/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs index ce7724699..1c605e4a4 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs @@ -69,7 +69,7 @@ private static void MapOtlpProxyEndpoint(IEndpointRouteBuilder group) async (HttpContext context, OtlpProxyUsecase proxyUsecase, Cancel ctx) => { var contentType = context.Request.ContentType ?? "application/json"; - var (statusCode, content) = await proxyUsecase.ProxyOtlp("traces", context.Request.Body, contentType, ctx); + var (statusCode, content) = await proxyUsecase.ProxyOtlp(OtlpSignalType.Traces, context.Request.Body, contentType, ctx); return Results.Content(content ?? string.Empty, contentType, statusCode: statusCode); }) .DisableAntiforgery(); // Frontend requests won't have antiforgery tokens @@ -80,7 +80,7 @@ private static void MapOtlpProxyEndpoint(IEndpointRouteBuilder group) async (HttpContext context, OtlpProxyUsecase proxyUsecase, Cancel ctx) => { var contentType = context.Request.ContentType ?? "application/json"; - var (statusCode, content) = await proxyUsecase.ProxyOtlp("logs", context.Request.Body, contentType, ctx); + var (statusCode, content) = await proxyUsecase.ProxyOtlp(OtlpSignalType.Logs, context.Request.Body, contentType, ctx); return Results.Content(content ?? string.Empty, contentType, statusCode: statusCode); }) .DisableAntiforgery(); @@ -91,7 +91,7 @@ private static void MapOtlpProxyEndpoint(IEndpointRouteBuilder group) async (HttpContext context, OtlpProxyUsecase proxyUsecase, Cancel ctx) => { var contentType = context.Request.ContentType ?? "application/json"; - var (statusCode, content) = await proxyUsecase.ProxyOtlp("metrics", context.Request.Body, contentType, ctx); + var (statusCode, content) = await proxyUsecase.ProxyOtlp(OtlpSignalType.Metrics, context.Request.Body, contentType, ctx); return Results.Content(content ?? string.Empty, contentType, statusCode: statusCode); }) .DisableAntiforgery(); diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs index 416035992..84c761922 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs @@ -186,6 +186,14 @@ private static void AddOtlpProxyUsecase(IServiceCollection services, AppEnv appE var config = sp.GetRequiredService(); return new OtlpProxyOptions(config); }); + + // Register named HttpClient for OTLP proxy + _ = services.AddHttpClient(AdotOtlpGateway.HttpClientName) + .ConfigureHttpClient(client => + { + client.Timeout = TimeSpan.FromSeconds(30); + }); + _ = services.AddScoped(); _ = services.AddScoped(); logger?.LogInformation("OTLP proxy configured to forward to ADOT Lambda Layer collector"); diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Examples/ServiceMockingExampleTests.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Examples/ServiceMockingExampleTests.cs index e27fd61d3..fb22503a4 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Examples/ServiceMockingExampleTests.cs +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Examples/ServiceMockingExampleTests.cs @@ -27,7 +27,7 @@ public async Task ExampleWithMultipleServiceMocks() // Configure mock behaviors A.CallTo(() => mockOtlpGateway.ForwardOtlp( - A._, + A._, A._, A._, A._)) @@ -80,7 +80,7 @@ public async Task ExampleWithSingletonMock() { // Arrange var mockGateway = A.Fake(); - A.CallTo(() => mockGateway.ForwardOtlp(A._, A._, A._, A._)) + A.CallTo(() => mockGateway.ForwardOtlp(A._, A._, A._, A._)) .Returns(Task.FromResult<(int, string?)>((503, "Unavailable"))); // Use ReplaceSingleton for services registered as singletons diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs index 3e3139515..c39275ee1 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs @@ -51,6 +51,14 @@ public static ApiWebApplicationFactory WithMockedServices(Action + /// Creates a factory with custom service configuration. + /// + /// Action to configure services directly + /// New factory instance with custom service configuration + public static ApiWebApplicationFactory WithMockedServices(Action configureServices) + => new(configureServices); + protected override void ConfigureWebHost(IWebHostBuilder builder) => builder.ConfigureServices(services => { var otelBuilder = services.AddOpenTelemetry(); diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs index ed31c714d..e4a22ad2e 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs @@ -4,10 +4,11 @@ using System.Net; using System.Text; -using Elastic.Documentation.Api.Core.Telemetry; +using Elastic.Documentation.Api.Infrastructure.Adapters.Telemetry; using Elastic.Documentation.Api.IntegrationTests.Fixtures; using FakeItEasy; using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace Elastic.Documentation.Api.IntegrationTests; @@ -15,21 +16,26 @@ namespace Elastic.Documentation.Api.IntegrationTests; public class OtlpProxyIntegrationTests { [Fact] - public async Task OtlpProxyTracesEndpointReturnsSuccess() + public async Task OtlpProxyTracesEndpointForwardsToCorrectUrl() { // Arrange - var mockGateway = A.Fake(); - A.CallTo(() => mockGateway.ForwardOtlp( - A._, - A._, - A._, - A._)) - .Returns(Task.FromResult<(int StatusCode, string? Content)>((200, "{}"))); + var mockHandler = A.Fake(); + var capturedRequest = (HttpRequestMessage?)null; + + A.CallTo(mockHandler) + .Where(call => call.Method.Name == "SendAsync") + .WithReturnType>() + .Invokes((HttpRequestMessage req, CancellationToken ct) => capturedRequest = req) + .Returns(Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") })); using var factory = ApiWebApplicationFactory.WithMockedServices(services => - services.Replace(mockGateway)); - using var client = factory.CreateClient(); + { + // Replace the named HttpClient with our mock + _ = services.AddHttpClient(AdotOtlpGateway.HttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => mockHandler); + }); + var client = factory.CreateClient(); var otlpPayload = /*lang=json,strict*/ """ { "resourceSpans": [{ @@ -48,34 +54,42 @@ public async Task OtlpProxyTracesEndpointReturnsSuccess() // Act var response = await client.PostAsync("/docs/_api/v1/o/t", content, TestContext.Current.CancellationToken); - // Assert - response.StatusCode.Should().Be(HttpStatusCode.OK); + // Assert - verify the request was forwarded to the correct URL + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + throw new Exception($"Test failed with {response.StatusCode}: {errorBody}"); + } - // Verify gateway was called - A.CallTo(() => mockGateway.ForwardOtlp( - "traces", - A._, - A._, - A._)) - .MustHaveHappenedOnceExactly(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri.Should().NotBeNull(); + capturedRequest.RequestUri!.ToString().Should().Be("http://localhost:4318/v1/traces"); + capturedRequest.Method.Should().Be(HttpMethod.Post); + capturedRequest.Content.Should().NotBeNull(); + capturedRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json"); } [Fact] - public async Task OtlpProxyLogsEndpointReturnsSuccess() + public async Task OtlpProxyLogsEndpointForwardsToCorrectUrl() { // Arrange - var mockGateway = A.Fake(); - A.CallTo(() => mockGateway.ForwardOtlp( - A._, - A._, - A._, - A._)) - .Returns(Task.FromResult<(int StatusCode, string? Content)>((200, "{}"))); + var mockHandler = A.Fake(); + var capturedRequest = (HttpRequestMessage?)null; + + A.CallTo(mockHandler) + .Where(call => call.Method.Name == "SendAsync") + .WithReturnType>() + .Invokes((HttpRequestMessage req, CancellationToken ct) => capturedRequest = req) + .Returns(Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") })); using var factory = ApiWebApplicationFactory.WithMockedServices(services => - services.Replace(mockGateway)); - using var client = factory.CreateClient(); + { + _ = services.AddHttpClient(AdotOtlpGateway.HttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => mockHandler); + }); + var client = factory.CreateClient(); var otlpPayload = /*lang=json,strict*/ """ { "resourceLogs": [{ @@ -97,34 +111,32 @@ public async Task OtlpProxyLogsEndpointReturnsSuccess() // Act var response = await client.PostAsync("/docs/_api/v1/o/l", content, TestContext.Current.CancellationToken); - // Assert + // Assert - verify the enum ToStringFast() generates "logs" (lowercase) response.StatusCode.Should().Be(HttpStatusCode.OK); - - // Verify gateway was called - A.CallTo(() => mockGateway.ForwardOtlp( - "logs", - A._, - A._, - A._)) - .MustHaveHappenedOnceExactly(); + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.ToString().Should().Be("http://localhost:4318/v1/logs"); } [Fact] - public async Task OtlpProxyMetricsEndpointReturnsSuccess() + public async Task OtlpProxyMetricsEndpointForwardsToCorrectUrl() { // Arrange - var mockGateway = A.Fake(); - A.CallTo(() => mockGateway.ForwardOtlp( - A._, - A._, - A._, - A._)) - .Returns(Task.FromResult<(int StatusCode, string? Content)>((200, "{}"))); + var mockHandler = A.Fake(); + var capturedRequest = (HttpRequestMessage?)null; + + A.CallTo(mockHandler) + .Where(call => call.Method.Name == "SendAsync") + .WithReturnType>() + .Invokes((HttpRequestMessage req, CancellationToken ct) => capturedRequest = req) + .Returns(Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") })); using var factory = ApiWebApplicationFactory.WithMockedServices(services => - services.Replace(mockGateway)); - using var client = factory.CreateClient(); + { + _ = services.AddHttpClient(AdotOtlpGateway.HttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => mockHandler); + }); + var client = factory.CreateClient(); var otlpPayload = /*lang=json,strict*/ """ { "resourceMetrics": [{ @@ -142,40 +154,39 @@ public async Task OtlpProxyMetricsEndpointReturnsSuccess() // Act var response = await client.PostAsync("/docs/_api/v1/o/m", content, TestContext.Current.CancellationToken); - // Assert + // Assert - verify the enum ToStringFast() generates "metrics" (lowercase) response.StatusCode.Should().Be(HttpStatusCode.OK); - - // Verify gateway was called - A.CallTo(() => mockGateway.ForwardOtlp( - "metrics", - A._, - A._, - A._)) - .MustHaveHappenedOnceExactly(); + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.ToString().Should().Be("http://localhost:4318/v1/metrics"); } [Fact] - public async Task OtlpProxyReturnsGatewayErrorStatusCode() + public async Task OtlpProxyReturnsCollectorErrorStatusCode() { // Arrange - var mockGateway = A.Fake(); - A.CallTo(() => mockGateway.ForwardOtlp( - A._, - A._, - A._, - A._)) - .Returns(Task.FromResult<(int StatusCode, string? Content)>((503, "Service unavailable"))); + var mockHandler = A.Fake(); + + A.CallTo(mockHandler) + .Where(call => call.Method.Name == "SendAsync") + .WithReturnType>() + .Returns(Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { + Content = new StringContent("Service unavailable") + })); using var factory = ApiWebApplicationFactory.WithMockedServices(services => - services.Replace(mockGateway)); - using var client = factory.CreateClient(); + { + _ = services.AddHttpClient(AdotOtlpGateway.HttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => mockHandler); + }); + var client = factory.CreateClient(); var content = new StringContent("{}", Encoding.UTF8, "application/json"); // Act var response = await client.PostAsync("/docs/_api/v1/o/t", content, TestContext.Current.CancellationToken); - // Assert + // Assert - verify error responses are properly forwarded response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); var responseBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); responseBody.Should().Contain("Service unavailable"); From 006b09629ee645f3df62aa5aeb06e231f7a4bdf0 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 20 Nov 2025 22:33:32 +0100 Subject: [PATCH 4/9] Fix CodeQL --- .../OtlpProxyIntegrationTests.cs | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs index e4a22ad2e..0d9b2abdb 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs @@ -26,7 +26,10 @@ public async Task OtlpProxyTracesEndpointForwardsToCorrectUrl() .Where(call => call.Method.Name == "SendAsync") .WithReturnType>() .Invokes((HttpRequestMessage req, CancellationToken ct) => capturedRequest = req) - .Returns(Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") })); + .Returns(Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}") + })); using var factory = ApiWebApplicationFactory.WithMockedServices(services => { @@ -49,10 +52,11 @@ public async Task OtlpProxyTracesEndpointForwardsToCorrectUrl() }] } """; - var content = new StringContent(otlpPayload, Encoding.UTF8, "application/json"); + + using var content = new StringContent(otlpPayload, Encoding.UTF8, "application/json"); // Act - var response = await client.PostAsync("/docs/_api/v1/o/t", content, TestContext.Current.CancellationToken); + using var response = await client.PostAsync("/docs/_api/v1/o/t", content, TestContext.Current.CancellationToken); // Assert - verify the request was forwarded to the correct URL if (!response.IsSuccessStatusCode) @@ -81,7 +85,10 @@ public async Task OtlpProxyLogsEndpointForwardsToCorrectUrl() .Where(call => call.Method.Name == "SendAsync") .WithReturnType>() .Invokes((HttpRequestMessage req, CancellationToken ct) => capturedRequest = req) - .Returns(Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") })); + .Returns(Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}") + })); using var factory = ApiWebApplicationFactory.WithMockedServices(services => { @@ -106,10 +113,11 @@ public async Task OtlpProxyLogsEndpointForwardsToCorrectUrl() }] } """; - var content = new StringContent(otlpPayload, Encoding.UTF8, "application/json"); + + using var content = new StringContent(otlpPayload, Encoding.UTF8, "application/json"); // Act - var response = await client.PostAsync("/docs/_api/v1/o/l", content, TestContext.Current.CancellationToken); + using var response = await client.PostAsync("/docs/_api/v1/o/l", content, TestContext.Current.CancellationToken); // Assert - verify the enum ToStringFast() generates "logs" (lowercase) response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -128,7 +136,10 @@ public async Task OtlpProxyMetricsEndpointForwardsToCorrectUrl() .Where(call => call.Method.Name == "SendAsync") .WithReturnType>() .Invokes((HttpRequestMessage req, CancellationToken ct) => capturedRequest = req) - .Returns(Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") })); + .Returns(Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}") + })); using var factory = ApiWebApplicationFactory.WithMockedServices(services => { @@ -149,10 +160,11 @@ public async Task OtlpProxyMetricsEndpointForwardsToCorrectUrl() }] } """; - var content = new StringContent(otlpPayload, Encoding.UTF8, "application/json"); + + using var content = new StringContent(otlpPayload, Encoding.UTF8, "application/json"); // Act - var response = await client.PostAsync("/docs/_api/v1/o/m", content, TestContext.Current.CancellationToken); + using var response = await client.PostAsync("/docs/_api/v1/o/m", content, TestContext.Current.CancellationToken); // Assert - verify the enum ToStringFast() generates "metrics" (lowercase) response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -181,10 +193,10 @@ public async Task OtlpProxyReturnsCollectorErrorStatusCode() }); var client = factory.CreateClient(); - var content = new StringContent("{}", Encoding.UTF8, "application/json"); + using var content = new StringContent("{}", Encoding.UTF8, "application/json"); // Act - var response = await client.PostAsync("/docs/_api/v1/o/t", content, TestContext.Current.CancellationToken); + using var response = await client.PostAsync("/docs/_api/v1/o/t", content, TestContext.Current.CancellationToken); // Assert - verify error responses are properly forwarded response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); @@ -198,10 +210,10 @@ public async Task OtlpProxyInvalidSignalTypeReturns404() // Arrange using var factory = new ApiWebApplicationFactory(); using var client = factory.CreateClient(); - var content = new StringContent("{}", Encoding.UTF8, "application/json"); + using var content = new StringContent("{}", Encoding.UTF8, "application/json"); // Act - use invalid signal type - var response = await client.PostAsync("/docs/_api/v1/o/invalid", content, TestContext.Current.CancellationToken); + using var response = await client.PostAsync("/docs/_api/v1/o/invalid", content, TestContext.Current.CancellationToken); // Assert - route doesn't exist response.StatusCode.Should().Be(HttpStatusCode.NotFound); From 05fbd7563e7465f37878eb230b8547c763b55945 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 20 Nov 2025 22:48:17 +0100 Subject: [PATCH 5/9] Refactor and dispose disposable --- .../AskAi/AgentBuilderAskAiGateway.cs | 4 +- .../Adapters/AskAi/LlmGatewayAskAiGateway.cs | 4 +- .../EuidEnrichmentIntegrationTests.cs | 49 +++++++-- .../Examples/ServiceMockingExampleTests.cs | 95 ---------------- .../Fixtures/ApiWebApplicationFactory.cs | 101 ++++++------------ .../OtlpProxyIntegrationTests.cs | 56 +++++++--- 6 files changed, 117 insertions(+), 192 deletions(-) delete mode 100644 tests-integration/Elastic.Documentation.Api.IntegrationTests/Examples/ServiceMockingExampleTests.cs diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs index cdf53b3c9..6748a40b1 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs @@ -37,7 +37,7 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) var kibanaUrl = await parameterProvider.GetParam("docs-kibana-url", false, ctx); var kibanaApiKey = await parameterProvider.GetParam("docs-kibana-apikey", true, ctx); - var request = new HttpRequestMessage(HttpMethod.Post, + using var request = new HttpRequestMessage(HttpMethod.Post, $"{kibanaUrl}/api/agent_builder/converse/async") { Content = new StringContent(requestBody, Encoding.UTF8, "application/json") @@ -45,7 +45,7 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) request.Headers.Add("kbn-xsrf", "true"); request.Headers.Authorization = new AuthenticationHeaderValue("ApiKey", kibanaApiKey); - var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx); + using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx); // Ensure the response is successful before streaming if (!response.IsSuccessStatusCode) diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs index f7d1cdf70..64e3c72ca 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs @@ -25,7 +25,7 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) { var llmGatewayRequest = LlmGatewayRequest.CreateFromRequest(askAiRequest); var requestBody = JsonSerializer.Serialize(llmGatewayRequest, LlmGatewayContext.Default.LlmGatewayRequest); - var request = new HttpRequestMessage(HttpMethod.Post, options.FunctionUrl) + using var request = new HttpRequestMessage(HttpMethod.Post, options.FunctionUrl) { Content = new StringContent(requestBody, Encoding.UTF8, "application/json") }; @@ -37,7 +37,7 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) // Use HttpCompletionOption.ResponseHeadersRead to get headers immediately // This allows us to start streaming as soon as headers are received - var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx); + using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx); // Ensure the response is successful before streaming if (!response.IsSuccessStatusCode) diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs index 3920b3296..f2f4e1425 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs @@ -7,18 +7,18 @@ using Elastic.Documentation.Api.Core; using Elastic.Documentation.Api.Core.AskAi; using Elastic.Documentation.Api.IntegrationTests.Fixtures; +using FakeItEasy; using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; namespace Elastic.Documentation.Api.IntegrationTests; /// /// Integration tests for euid cookie enrichment in OpenTelemetry traces and logging. -/// Uses WebApplicationFactory to test the real API configuration with mocked services. +/// Uses WebApplicationFactory to test the real API configuration with mocked AskAi services. /// -public class EuidEnrichmentIntegrationTests(ApiWebApplicationFactory factory) : IClassFixture +public class EuidEnrichmentIntegrationTests { - private readonly ApiWebApplicationFactory _factory = factory; - /// /// Test that verifies euid cookie is added to both HTTP span and custom AskAi span, /// and appears in log entries - using the real API configuration. @@ -29,8 +29,39 @@ public async Task AskAiEndpointPropagatatesEuidToAllSpansAndLogs() // Arrange const string expectedEuid = "integration-test-euid-12345"; + // Track streams created by mocks so we can dispose them after the test + var mockStreams = new List(); + + // Create factory with mocked AskAi services + using var factory = ApiWebApplicationFactory.WithMockedServices(services => + { + // Mock IAskAiGateway to avoid external AI service calls + var mockAskAiGateway = A.Fake>(); + A.CallTo(() => mockAskAiGateway.AskAi(A._, A._)) + .ReturnsLazily(() => + { + var stream = new MemoryStream(Encoding.UTF8.GetBytes("data: test\n\n")); + mockStreams.Add(stream); + return Task.FromResult(stream); + }); + services.AddSingleton(mockAskAiGateway); + + // Mock IStreamTransformer + var mockTransformer = A.Fake(); + A.CallTo(() => mockTransformer.AgentProvider).Returns("test-provider"); + A.CallTo(() => mockTransformer.AgentId).Returns("test-agent"); + A.CallTo(() => mockTransformer.TransformAsync(A._, A._, A._, A._)) + .ReturnsLazily((Stream s, string? _, Activity? activity, Cancel _) => + { + // Dispose the activity if provided (simulating what the real transformer does) + activity?.Dispose(); + return Task.FromResult(s); + }); + services.AddSingleton(mockTransformer); + }); + // Create client - using var client = _factory.CreateClient(); + using var client = factory.CreateClient(); // Act - Make request to /ask-ai/stream with euid cookie using var request = new HttpRequestMessage(HttpMethod.Post, "/docs/_api/v1/ask-ai/stream"); @@ -48,7 +79,7 @@ public async Task AskAiEndpointPropagatatesEuidToAllSpansAndLogs() response.IsSuccessStatusCode.Should().BeTrue(); // Assert - Verify spans were captured - var activities = _factory.ExportedActivities; + var activities = factory.ExportedActivities; activities.Should().NotBeEmpty("OpenTelemetry should have captured activities"); // Verify HTTP span has euid @@ -67,7 +98,7 @@ public async Task AskAiEndpointPropagatatesEuidToAllSpansAndLogs() askAiEuidTag.Value.Should().Be(expectedEuid, "AskAi span euid should match cookie value"); // Assert - Verify logs have euid in attributes - var logRecords = _factory.ExportedLogRecords; + var logRecords = factory.ExportedLogRecords; logRecords.Should().NotBeEmpty("Should have captured log records"); // Find a log entry from AskAiUsecase @@ -80,5 +111,9 @@ public async Task AskAiEndpointPropagatatesEuidToAllSpansAndLogs() var euidAttribute = askAiLogRecord!.Attributes?.FirstOrDefault(a => a.Key == TelemetryConstants.UserEuidAttributeName) ?? default; euidAttribute.Should().NotBe(default(KeyValuePair), "Log record should include user.euid attribute"); (euidAttribute.Value?.ToString() ?? string.Empty).Should().Be(expectedEuid, "Log record euid should match cookie value"); + + // Cleanup - dispose all mock streams + foreach (var stream in mockStreams) + stream.Dispose(); } } diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Examples/ServiceMockingExampleTests.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Examples/ServiceMockingExampleTests.cs deleted file mode 100644 index fb22503a4..000000000 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Examples/ServiceMockingExampleTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using System.Net; -using Elastic.Documentation.Api.Core.Search; -using Elastic.Documentation.Api.Core.Telemetry; -using Elastic.Documentation.Api.IntegrationTests.Fixtures; -using FakeItEasy; -using FluentAssertions; -using Xunit; - -namespace Elastic.Documentation.Api.IntegrationTests.Examples; - -/// -/// Example test demonstrating how to mock multiple services in integration tests. -/// This serves as documentation for the service mocking pattern. -/// -public class ServiceMockingExampleTests -{ - [Fact] - public async Task ExampleWithMultipleServiceMocks() - { - // Arrange - Create multiple mocks - var mockOtlpGateway = A.Fake(); - var mockSearchGateway = A.Fake(); - - // Configure mock behaviors - A.CallTo(() => mockOtlpGateway.ForwardOtlp( - A._, - A._, - A._, - A._)) - .Returns(Task.FromResult<(int StatusCode, string? Content)>((200, "{}}"))); - - A.CallTo(() => mockSearchGateway.SearchAsync( - A._, - A._, - A._, - A._)) - .Returns(Task.FromResult((TotalHits: 1, Results: new List - { - new() - { - Type = "page", - Url = "/docs/test", - Title = "Test Result", - Description = "A test result", - Parents = [] - } - }))); - - // Create factory with multiple mocked services using fluent API - using var factory = ApiWebApplicationFactory.WithMockedServices(services => - services - .Replace(mockOtlpGateway) // Replace IOtlpGateway - .Replace(mockSearchGateway)); // Replace ISearchGateway - - using var client = factory.CreateClient(); - - // Act - Make a search request that uses the mocked search gateway - var searchResponse = await client.GetAsync( - "/docs/_api/v1/search?q=test&page=1&pageSize=5", - TestContext.Current.CancellationToken); - - // Assert - searchResponse.StatusCode.Should().Be(HttpStatusCode.OK); - - // Verify the search gateway was called with correct parameters - A.CallTo(() => mockSearchGateway.SearchAsync( - "test", // query - 1, // page - 5, // pageSize (default in API) - A._)) - .MustHaveHappenedOnceExactly(); - } - - [Fact] - public async Task ExampleWithSingletonMock() - { - // Arrange - var mockGateway = A.Fake(); - A.CallTo(() => mockGateway.ForwardOtlp(A._, A._, A._, A._)) - .Returns(Task.FromResult<(int, string?)>((503, "Unavailable"))); - - // Use ReplaceSingleton for services registered as singletons - using var factory = ApiWebApplicationFactory.WithMockedServices(services => - services.ReplaceSingleton(mockGateway)); - - using var client = factory.CreateClient(); - - // Act & Assert - // Your test logic here... - } -} diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs index c39275ee1..f6483b21e 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs @@ -3,9 +3,6 @@ // See the LICENSE file in the project root for more information using System.Diagnostics; -using System.Text; -using Elastic.Documentation.Api.Core.AskAi; -using Elastic.Documentation.Api.Core.Telemetry; using Elastic.Documentation.Api.Infrastructure; using Elastic.Documentation.Api.Infrastructure.Aws; using Elastic.Documentation.Api.Infrastructure.OpenTelemetry; @@ -14,7 +11,6 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; using OpenTelemetry; using OpenTelemetry.Logs; using OpenTelemetry.Trace; @@ -24,12 +20,13 @@ namespace Elastic.Documentation.Api.IntegrationTests.Fixtures; /// /// Custom WebApplicationFactory for testing the API with mocked services. /// This fixture can be reused across multiple test classes. +/// Only mocks services that ALL tests need (OpenTelemetry, AWS Parameters). +/// Test-specific mocks should be configured using WithMockedServices. /// public class ApiWebApplicationFactory : WebApplicationFactory { public List ExportedActivities { get; } = []; public List ExportedLogRecords { get; } = []; - private readonly List _mockMemoryStreams = []; private readonly Action? _configureServices; public ApiWebApplicationFactory() : this(null) @@ -60,67 +57,31 @@ public static ApiWebApplicationFactory WithMockedServices(Action new(configureServices); protected override void ConfigureWebHost(IWebHostBuilder builder) => builder.ConfigureServices(services => - { - var otelBuilder = services.AddOpenTelemetry(); - _ = otelBuilder.WithTracing(tracing => - { - _ = tracing - .AddDocsApiTracing() // Reuses production configuration - .AddInMemoryExporter(ExportedActivities); - }); - _ = otelBuilder.WithLogging(logging => - { - _ = logging - .AddDocsApiLogging() // Reuses production configuration - .AddInMemoryExporter(ExportedLogRecords); - }); - - // Mock IParameterProvider to avoid AWS dependencies - var mockParameterProvider = A.Fake(); - A.CallTo(() => mockParameterProvider.GetParam(A._, A._, A._)) - .Returns(Task.FromResult("mock-value")); - _ = services.AddSingleton(mockParameterProvider); - - // Mock IAskAiGateway to avoid external AI service calls - var mockAskAiGateway = A.Fake>(); - A.CallTo(() => mockAskAiGateway.AskAi(A._, A._)) - .ReturnsLazily(() => - { - var stream = new MemoryStream(Encoding.UTF8.GetBytes("data: test\n\n")); - _mockMemoryStreams.Add(stream); - return Task.FromResult(stream); - }); - _ = services.AddSingleton(mockAskAiGateway); - - // Mock IStreamTransformer - var mockTransformer = A.Fake(); - A.CallTo(() => mockTransformer.AgentProvider).Returns("test-provider"); - A.CallTo(() => mockTransformer.AgentId).Returns("test-agent"); - A.CallTo(() => mockTransformer.TransformAsync(A._, A._, A._, A._)) - .ReturnsLazily((Stream s, string? _, Activity? activity, Cancel _) => - { - // Dispose the activity if provided (simulating what the real transformer does) - activity?.Dispose(); - return Task.FromResult(s); - }); - _ = services.AddSingleton(mockTransformer); - - // Allow tests to override services - RemoveAll + Add to properly replace - _configureServices?.Invoke(services); - }); - - protected override void Dispose(bool disposing) { - if (disposing) + // Configure OpenTelemetry with in-memory exporters for all tests + var otelBuilder = services.AddOpenTelemetry(); + _ = otelBuilder.WithTracing(tracing => { - foreach (var stream in _mockMemoryStreams) - { - stream.Dispose(); - } - _mockMemoryStreams.Clear(); - } - base.Dispose(disposing); - } + _ = tracing + .AddDocsApiTracing() // Reuses production configuration + .AddInMemoryExporter(ExportedActivities); + }); + _ = otelBuilder.WithLogging(logging => + { + _ = logging + .AddDocsApiLogging() // Reuses production configuration + .AddInMemoryExporter(ExportedLogRecords); + }); + + // Mock IParameterProvider to avoid AWS dependencies in all tests + var mockParameterProvider = A.Fake(); + A.CallTo(() => mockParameterProvider.GetParam(A._, A._, A._)) + .Returns(Task.FromResult("mock-value")); + _ = services.AddSingleton(mockParameterProvider); + + // Apply test-specific service replacements (if any) + _configureServices?.Invoke(services); + }); } /// @@ -183,10 +144,10 @@ public ServiceReplacementBuilder ReplaceSingleton(TService instance) w /// Builds the final service configuration action. /// internal Action Build() => services => - { - foreach (var replacement in _replacements) - { - replacement(services); - } - }; + { + foreach (var replacement in _replacements) + { + replacement(services); + } + }; } diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs index 0d9b2abdb..bcecdab1d 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs @@ -22,14 +22,17 @@ public async Task OtlpProxyTracesEndpointForwardsToCorrectUrl() var mockHandler = A.Fake(); var capturedRequest = (HttpRequestMessage?)null; + // Create mock response (will be disposed by HttpClient) + var mockResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}") + }; + A.CallTo(mockHandler) .Where(call => call.Method.Name == "SendAsync") .WithReturnType>() .Invokes((HttpRequestMessage req, CancellationToken ct) => capturedRequest = req) - .Returns(Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{}") - })); + .Returns(Task.FromResult(mockResponse)); using var factory = ApiWebApplicationFactory.WithMockedServices(services => { @@ -72,6 +75,9 @@ public async Task OtlpProxyTracesEndpointForwardsToCorrectUrl() capturedRequest.Method.Should().Be(HttpMethod.Post); capturedRequest.Content.Should().NotBeNull(); capturedRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json"); + + // Cleanup mock response + mockResponse.Dispose(); } [Fact] @@ -81,14 +87,17 @@ public async Task OtlpProxyLogsEndpointForwardsToCorrectUrl() var mockHandler = A.Fake(); var capturedRequest = (HttpRequestMessage?)null; + // Create mock response (will be disposed by HttpClient) + var mockResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}") + }; + A.CallTo(mockHandler) .Where(call => call.Method.Name == "SendAsync") .WithReturnType>() .Invokes((HttpRequestMessage req, CancellationToken ct) => capturedRequest = req) - .Returns(Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{}") - })); + .Returns(Task.FromResult(mockResponse)); using var factory = ApiWebApplicationFactory.WithMockedServices(services => { @@ -123,6 +132,9 @@ public async Task OtlpProxyLogsEndpointForwardsToCorrectUrl() response.StatusCode.Should().Be(HttpStatusCode.OK); capturedRequest.Should().NotBeNull(); capturedRequest!.RequestUri!.ToString().Should().Be("http://localhost:4318/v1/logs"); + + // Cleanup mock response + mockResponse.Dispose(); } [Fact] @@ -132,14 +144,17 @@ public async Task OtlpProxyMetricsEndpointForwardsToCorrectUrl() var mockHandler = A.Fake(); var capturedRequest = (HttpRequestMessage?)null; + // Create mock response (will be disposed by HttpClient) + var mockResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}") + }; + A.CallTo(mockHandler) .Where(call => call.Method.Name == "SendAsync") .WithReturnType>() .Invokes((HttpRequestMessage req, CancellationToken ct) => capturedRequest = req) - .Returns(Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{}") - })); + .Returns(Task.FromResult(mockResponse)); using var factory = ApiWebApplicationFactory.WithMockedServices(services => { @@ -170,6 +185,9 @@ public async Task OtlpProxyMetricsEndpointForwardsToCorrectUrl() response.StatusCode.Should().Be(HttpStatusCode.OK); capturedRequest.Should().NotBeNull(); capturedRequest!.RequestUri!.ToString().Should().Be("http://localhost:4318/v1/metrics"); + + // Cleanup mock response + mockResponse.Dispose(); } [Fact] @@ -178,13 +196,16 @@ public async Task OtlpProxyReturnsCollectorErrorStatusCode() // Arrange var mockHandler = A.Fake(); + // Create mock response (will be disposed by HttpClient) + var mockResponse = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { + Content = new StringContent("Service unavailable") + }; + A.CallTo(mockHandler) .Where(call => call.Method.Name == "SendAsync") .WithReturnType>() - .Returns(Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { - Content = new StringContent("Service unavailable") - })); + .Returns(Task.FromResult(mockResponse)); using var factory = ApiWebApplicationFactory.WithMockedServices(services => { @@ -202,6 +223,9 @@ public async Task OtlpProxyReturnsCollectorErrorStatusCode() response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); var responseBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); responseBody.Should().Contain("Service unavailable"); + + // Cleanup mock response + mockResponse.Dispose(); } [Fact] From 45f6f4dc25eedab5bcdc2701983700b7668fd0f4 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Sat, 22 Nov 2025 00:43:54 +0100 Subject: [PATCH 6/9] Set up frontend instrumentation and other OTel related refactorings --- Directory.Packages.props | 1 - .../Assets/custom-elements.ts | 2 - .../Assets/image-carousel.ts | 5 - src/Elastic.Documentation.Site/Assets/main.ts | 20 + .../Assets/open-details-with-anchor.ts | 3 +- .../Assets/telemetry/instrumentation.ts | 306 ++++ .../Assets/telemetry/logging.ts | 102 ++ .../Assets/telemetry/semconv.ts | 121 ++ .../Assets/telemetry/tracing.ts | 40 + .../SearchOrAskAi/AskAi/Chat.tsx | 6 - .../AskAi/StreamingAiMessage.tsx | 16 - .../SearchOrAskAi/AskAi/useAskAi.ts | 6 - .../AskAi/useFetchEventSource.ts | 4 - .../AskAi/useMessageThrottling.ts | 2 +- .../AskAi/useStatusMinDisplay.ts | 2 +- .../Search/SearchResults/SearchResults.tsx | 2 + .../SearchResults/SearchResultsListItem.tsx | 65 + .../SearchOrAskAi/Search/useSearchQuery.ts | 63 +- .../Elastic.Documentation.Site.csproj | 9 +- .../FileProviders/Preloader.cs | 3 + .../Layout/_Head.cshtml | 1 - .../eslint.config.mjs | 18 + .../package-lock.json | 1264 +++++++++++++++-- src/Elastic.Documentation.Site/package.json | 21 +- .../AskAi/AskAiUsecase.cs | 2 +- .../Search/SearchUsecase.cs | 1 + .../Telemetry/IOtlpGateway.cs | 4 +- .../Telemetry/OtlpForwardResult.cs | 26 + .../Telemetry/OtlpProxyOptions.cs | 30 +- .../Telemetry/OtlpProxyUsecase.cs | 6 +- .../AskAi/AgentBuilderAskAiGateway.cs | 4 +- .../Adapters/AskAi/LlmGatewayAskAiGateway.cs | 4 +- .../Adapters/AskAi/StreamTransformerBase.cs | 4 +- .../Adapters/Telemetry/AdotOtlpGateway.cs | 54 +- .../MappingsExtensions.cs | 44 +- .../OpenTelemetry/OpenTelemetryExtensions.cs | 8 + ....Documentation.Api.IntegrationTests.csproj | 1 - .../OtlpProxyIntegrationTests.cs | 8 +- 38 files changed, 2030 insertions(+), 248 deletions(-) delete mode 100644 src/Elastic.Documentation.Site/Assets/custom-elements.ts create mode 100644 src/Elastic.Documentation.Site/Assets/telemetry/instrumentation.ts create mode 100644 src/Elastic.Documentation.Site/Assets/telemetry/logging.ts create mode 100644 src/Elastic.Documentation.Site/Assets/telemetry/semconv.ts create mode 100644 src/Elastic.Documentation.Site/Assets/telemetry/tracing.ts create mode 100644 src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpForwardResult.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 05320f0cd..0d5d29ab7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -42,7 +42,6 @@ - diff --git a/src/Elastic.Documentation.Site/Assets/custom-elements.ts b/src/Elastic.Documentation.Site/Assets/custom-elements.ts deleted file mode 100644 index e24d57208..000000000 --- a/src/Elastic.Documentation.Site/Assets/custom-elements.ts +++ /dev/null @@ -1,2 +0,0 @@ -import './web-components/SearchOrAskAi/SearchOrAskAi' -import './web-components/VersionDropdown' diff --git a/src/Elastic.Documentation.Site/Assets/image-carousel.ts b/src/Elastic.Documentation.Site/Assets/image-carousel.ts index fe4e33ab0..e70424ea0 100644 --- a/src/Elastic.Documentation.Site/Assets/image-carousel.ts +++ b/src/Elastic.Documentation.Site/Assets/image-carousel.ts @@ -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` - ) } } } diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts index f5079d744..38f9fcfd6 100644 --- a/src/Elastic.Documentation.Site/Assets/main.ts +++ b/src/Elastic.Documentation.Site/Assets/main.ts @@ -7,6 +7,7 @@ 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' @@ -14,6 +15,25 @@ 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' diff --git a/src/Elastic.Documentation.Site/Assets/open-details-with-anchor.ts b/src/Elastic.Documentation.Site/Assets/open-details-with-anchor.ts index dd715904e..a1a3ebb06 100644 --- a/src/Elastic.Documentation.Site/Assets/open-details-with-anchor.ts +++ b/src/Elastic.Documentation.Site/Assets/open-details-with-anchor.ts @@ -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() { diff --git a/src/Elastic.Documentation.Site/Assets/telemetry/instrumentation.ts b/src/Elastic.Documentation.Site/Assets/telemetry/instrumentation.ts new file mode 100644 index 000000000..e7de8876a --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/telemetry/instrumentation.ts @@ -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 = { + [ATTR_SERVICE_NAME]: config.serviceName, + [ATTR_SERVICE_VERSION]: config.serviceVersion, + } + return resourceFromAttributes(resourceAttributes) +} + +function createCommonHeaders(): Record { + return { + 'X-Docs-Session': 'active', + } +} + +function initializeTracing( + resource: ReturnType, + config: ResolvedConfig, + commonHeaders: Record +): 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, + config: ResolvedConfig, + commonHeaders: Record +): 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 { + if (!isInitialized) { + return + } + + const flushPromises: Promise[] = [] + + 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((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 { + return Promise.resolve() + } + + forceFlush(): Promise { + return Promise.resolve() + } +} + +export interface OtelConfigOptions { + serviceName?: string + serviceVersion?: string + baseUrl?: string + debug?: boolean +} + +interface ResolvedConfig { + serviceName: string + serviceVersion: string + baseUrl: string + debug: boolean +} diff --git a/src/Elastic.Documentation.Site/Assets/telemetry/logging.ts b/src/Elastic.Documentation.Site/Assets/telemetry/logging.ts new file mode 100644 index 000000000..4d88f630a --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/telemetry/logging.ts @@ -0,0 +1,102 @@ +/** + * Logging utilities for frontend application. + * Provides structured logging functions that send logs to the backend via OTLP. + * + * Based on: https://signoz.io/docs/frontend-monitoring/sending-logs-with-opentelemetry/ + */ +import { logs, SeverityNumber, type AnyValueMap } from '@opentelemetry/api-logs' + +const logger = logs.getLogger('docs-frontend-logger') + +/** + * Log an informational message. + * + * @param body The log message + * @param attrs Additional attributes to attach to the log + * + * @example + * ```ts + * logInfo('User clicked search button', { + * 'user.action': 'search', + * 'search.query': query + * }) + * ``` + */ +export function logInfo(body: string, attrs: AnyValueMap = {}) { + logger.emit({ + body, + severityNumber: SeverityNumber.INFO, + severityText: 'INFO', + attributes: attrs, + }) +} + +/** + * Log a warning message. + * + * @param body The log message + * @param attrs Additional attributes to attach to the log + * + * @example + * ```ts + * logWarn('Search returned no results', { + * 'search.query': query, + * 'search.duration_ms': duration + * }) + * ``` + */ +export function logWarn(body: string, attrs: AnyValueMap = {}) { + logger.emit({ + body, + severityNumber: SeverityNumber.WARN, + severityText: 'WARN', + attributes: attrs, + }) +} + +/** + * Log an error message. + * + * @param body The log message + * @param attrs Additional attributes to attach to the log + * + * @example + * ```ts + * logError('Failed to fetch search results', { + * 'error.message': error.message, + * 'error.stack': error.stack, + * 'search.query': query + * }) + * ``` + */ +export function logError(body: string, attrs: AnyValueMap = {}) { + logger.emit({ + body, + severityNumber: SeverityNumber.ERROR, + severityText: 'ERROR', + attributes: attrs, + }) +} + +/** + * Log a debug message (only useful in development). + * + * @param body The log message + * @param attrs Additional attributes to attach to the log + * + * @example + * ```ts + * logDebug('Component rendered', { + * 'component.name': 'SearchResults', + * 'render.time_ms': renderTime + * }) + * ``` + */ +export function logDebug(body: string, attrs: AnyValueMap = {}) { + logger.emit({ + body, + severityNumber: SeverityNumber.DEBUG, + severityText: 'DEBUG', + attributes: attrs, + }) +} diff --git a/src/Elastic.Documentation.Site/Assets/telemetry/semconv.ts b/src/Elastic.Documentation.Site/Assets/telemetry/semconv.ts new file mode 100644 index 000000000..18e48edc9 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/telemetry/semconv.ts @@ -0,0 +1,121 @@ +/** + * Semantic Conventions for Documentation Site Telemetry + * + * This file defines custom attribute names for search telemetry. + * Standard OpenTelemetry semconv attributes are imported from @opentelemetry/semantic-conventions. + * + * References: + * - https://opentelemetry.io/docs/specs/semconv/ + * - https://opentelemetry.io/docs/specs/semconv/attributes-registry/ + */ + +// Re-export standard OpenTelemetry semantic conventions +export { + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, + ATTR_HTTP_RESPONSE_STATUS_CODE, + ATTR_ERROR_TYPE, +} from '@opentelemetry/semantic-conventions' + +// ============================================================================ +// SEARCH ATTRIBUTES (Custom) +// ============================================================================ + +/** + * The search query string entered by the user + * @example "elasticsearch aggregations" + */ +export const ATTR_SEARCH_QUERY = 'search.query' + +/** + * Length of the search query string + * @example 25 + */ +export const ATTR_SEARCH_QUERY_LENGTH = 'search.query.length' + +/** + * Current page number in search results (0-based) + * @example 0 + */ +export const ATTR_SEARCH_PAGE = 'search.page' + +/** + * Total number of search results found + * @example 142 + */ +export const ATTR_SEARCH_RESULTS_TOTAL = 'search.results.total' + +/** + * Number of results returned in current page + * @example 10 + */ +export const ATTR_SEARCH_RESULTS_COUNT = 'search.results.count' + +/** + * Total number of pages available + * @example 15 + */ +export const ATTR_SEARCH_PAGE_COUNT = 'search.page.count' + +/** + * Whether the search query was empty + * @example true + */ +export const ATTR_SEARCH_EMPTY_QUERY = 'search.empty_query' + +/** + * Whether the search resulted in an error + * @example false + */ +export const ATTR_SEARCH_ERROR = 'search.error' + +// ============================================================================ +// SEARCH RESULT CLICK ATTRIBUTES (Custom) +// ============================================================================ + +/** + * URL of the clicked search result + * @example "/docs/elasticsearch/reference/current/search-aggregations.html" + */ +export const ATTR_SEARCH_RESULT_URL = 'search.result.url' + +/** + * Title of the clicked search result + * @example "Aggregations" + */ +export const ATTR_SEARCH_RESULT_TITLE = 'search.result.title' + +/** + * Absolute position of the result across all pages (0-based) + * @example 23 + */ +export const ATTR_SEARCH_RESULT_POSITION = 'search.result.position' + +/** + * Position of the result within the current page (0-based) + * @example 3 + */ +export const ATTR_SEARCH_RESULT_POSITION_ON_PAGE = + 'search.result.position_on_page' + +/** + * Relevance score of the search result + * @example 0.85 + */ +export const ATTR_SEARCH_RESULT_SCORE = 'search.result.score' + +// ============================================================================ +// EVENT ATTRIBUTES (Custom) +// ============================================================================ + +/** + * Name of the event being tracked + * @example "search_result_clicked" + */ +export const ATTR_EVENT_NAME = 'event.name' + +/** + * Category of the event + * @example "ui" + */ +export const ATTR_EVENT_CATEGORY = 'event.category' diff --git a/src/Elastic.Documentation.Site/Assets/telemetry/tracing.ts b/src/Elastic.Documentation.Site/Assets/telemetry/tracing.ts new file mode 100644 index 000000000..ea89796f5 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/telemetry/tracing.ts @@ -0,0 +1,40 @@ +/** + * React utilities for OpenTelemetry tracing in components. + */ +import { trace, context, SpanStatusCode, Span } from '@opentelemetry/api' + +export async function traceSpan( + spanName: string, + fn: (span: Span) => Promise, + attributes?: Record +): Promise { + const tracer = trace.getTracer('docs-frontend') + const span = tracer.startSpan(spanName, undefined, context.active()) + + if (attributes) { + span.setAttributes(attributes) + } + + try { + const result = await fn(span) + span.setStatus({ code: SpanStatusCode.OK }) + return result + } catch (error) { + // Check if this is an AbortError (user cancelled/typed more) + if (error instanceof Error && error.name === 'AbortError') { + // Cancellation is NOT an error - it's expected behavior + span.setAttribute('cancelled', true) + span.setStatus({ code: SpanStatusCode.OK }) + } else { + // Real error - mark as ERROR + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }) + span.recordException(error as Error) + } + throw error + } finally { + span.end() + } +} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx index 1c50ff1fc..0d742cade 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx @@ -97,7 +97,6 @@ export const Chat = () => { // Handle abort function from StreamingAiMessage const handleAbortReady = (abort: () => void) => { - console.log('[Chat] Abort function ready, storing in ref') abortFunctionRef.current = abort } @@ -133,13 +132,8 @@ export const Chat = () => { ) const handleButtonClick = useCallback(() => { - console.log('[Chat] Button clicked', { - isStreaming, - hasAbortFunction: !!abortFunctionRef.current, - }) if (isStreaming && abortFunctionRef.current) { // Interrupt current query - console.log('[Chat] Calling abort function') abortFunctionRef.current() abortFunctionRef.current = null // Update message status from 'streaming' to 'complete' diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/StreamingAiMessage.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/StreamingAiMessage.tsx index c89e53ac7..069e1c4ba 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/StreamingAiMessage.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/StreamingAiMessage.tsx @@ -62,13 +62,6 @@ export const StreamingAiMessage = ({ } }, onError: (error: ApiError | Error | null) => { - console.error('[AI Provider] Error in StreamingAiMessage:', { - messageId: message.id, - errorMessage: error?.message, - errorStack: error?.stack, - errorName: error?.name, - fullError: error, - }) updateAiMessage( message.id, message.content || error?.message || 'Error occurred', @@ -80,16 +73,7 @@ export const StreamingAiMessage = ({ // Expose abort function to parent when this is the last message useEffect(() => { - console.log('[StreamingAiMessage] Effect triggered', { - isLast, - status: message.status, - hasAbort: !!abort, - hasCallback: !!onAbortReady, - }) if (isLast && message.status === 'streaming') { - console.log( - '[StreamingAiMessage] Calling onAbortReady with abort function' - ) onAbortReady?.(abort) } }, [isLast, message.status, abort, onAbortReady]) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useAskAi.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useAskAi.ts index d88e99b05..2005224a9 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useAskAi.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useAskAi.ts @@ -41,11 +41,6 @@ export const useAskAi = (props: Props): UseAskAiResponse => { // Get AI provider from store (user-controlled via UI) const aiProvider = useAiProvider() - // Log which provider is being used for this conversation - useEffect(() => { - console.log(`[AI Provider] Using ${aiProvider} for this conversation`) - }, [aiProvider]) - // Prepare headers with AI provider const headers = useMemo( () => ({ @@ -186,7 +181,6 @@ export const useAskAi = (props: Props): UseAskAiResponse => { error, sendQuestion, abort: () => { - console.log('[useAskAi] Abort called') abort() clearQueue() }, diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useFetchEventSource.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useFetchEventSource.ts index 3b784c47f..6336840a2 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useFetchEventSource.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useFetchEventSource.ts @@ -51,11 +51,7 @@ export function useFetchEventSource({ const abortControllerRef = useRef(null) const abort = useCallback(() => { - console.log('[useFetchEventSource] Abort called', { - hasController: !!abortControllerRef.current, - }) if (abortControllerRef.current) { - console.log('[useFetchEventSource] Aborting controller') abortControllerRef.current.abort() abortControllerRef.current = null } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageThrottling.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageThrottling.ts index 978ff48da..de5dfc45e 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageThrottling.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageThrottling.ts @@ -18,7 +18,7 @@ export function useMessageThrottling({ onMessage, }: UseMessageThrottlingOptions): UseMessageThrottlingReturn { const messageQueueRef = useRef([]) - const timerRef = useRef(null) + const timerRef = useRef | null>(null) const isProcessingRef = useRef(false) const processNextMessage = useCallback(() => { diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useStatusMinDisplay.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useStatusMinDisplay.ts index ae4f78fb8..236bb5b4d 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useStatusMinDisplay.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useStatusMinDisplay.ts @@ -36,7 +36,7 @@ export const useStatusMinDisplay = ( ) const lastChangeTimeRef = useRef(Date.now()) const pendingStatusRef = useRef(null) - const timeoutRef = useRef(null) + const timeoutRef = useRef | null>(null) useEffect(() => { // Clear any pending timeout diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx index a53c27c5d..7821029b7 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx @@ -51,6 +51,8 @@ export const SearchResults = ({ item={result} key={result.url} index={index} + pageNumber={data.pageNumber} + pageSize={data.pageSize} onKeyDown={onKeyDown} setRef={setItemRef} /> diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx index 28e07a43c..031d189c8 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx @@ -1,4 +1,16 @@ /** @jsxImportSource @emotion/react */ +import { + ATTR_SEARCH_QUERY, + ATTR_SEARCH_RESULT_URL, + ATTR_SEARCH_RESULT_TITLE, + ATTR_SEARCH_RESULT_POSITION, + ATTR_SEARCH_RESULT_POSITION_ON_PAGE, + ATTR_SEARCH_RESULT_SCORE, + ATTR_SEARCH_PAGE, + ATTR_EVENT_NAME, + ATTR_EVENT_CATEGORY, +} from '../../../../telemetry/semconv' +import { useSearchTerm } from '../search.store' import { type SearchResultItem } from '../useSearchQuery' import { EuiText, @@ -8,12 +20,42 @@ import { EuiSpacer, } from '@elastic/eui' import { css } from '@emotion/react' +import { trace } from '@opentelemetry/api' import DOMPurify from 'dompurify' import { memo, useMemo } from 'react' +function trackSearchResultClick(params: { + query: string + resultUrl: string + resultTitle: string + absolutePosition: number + positionOnPage: number + pageNumber: number + score: number +}): void { + const tracer = trace.getTracer('docs-frontend') + const span = tracer.startSpan('click search_result') + + span.setAttribute(ATTR_SEARCH_QUERY, params.query) + span.setAttribute(ATTR_SEARCH_RESULT_URL, params.resultUrl) + span.setAttribute(ATTR_SEARCH_RESULT_TITLE, params.resultTitle) + span.setAttribute(ATTR_SEARCH_RESULT_POSITION, params.absolutePosition) + span.setAttribute( + ATTR_SEARCH_RESULT_POSITION_ON_PAGE, + params.positionOnPage + ) + span.setAttribute(ATTR_SEARCH_PAGE, params.pageNumber) + span.setAttribute(ATTR_SEARCH_RESULT_SCORE, params.score) + span.setAttribute(ATTR_EVENT_NAME, 'search_result_clicked') + span.setAttribute(ATTR_EVENT_CATEGORY, 'ui') + span.end() +} + interface SearchResultListItemProps { item: SearchResultItem index: number + pageNumber: number + pageSize: number onKeyDown?: (e: React.KeyboardEvent, index: number) => void setRef?: (element: HTMLAnchorElement | null, index: number) => void } @@ -21,11 +63,31 @@ interface SearchResultListItemProps { export function SearchResultListItem({ item: result, index, + pageNumber, + pageSize, onKeyDown, setRef, }: SearchResultListItemProps) { const { euiTheme } = useEuiTheme() const titleFontSize = useEuiFontSize('s') + const searchQuery = useSearchTerm() + + // Calculate absolute position across all pages + // pageNumber is 0-based, so multiply by pageSize and add the index + const absolutePosition = pageNumber * pageSize + index + + const handleClick = () => { + trackSearchResultClick({ + query: searchQuery, + resultUrl: result.url, + resultTitle: result.title, + absolutePosition, + positionOnPage: index, + pageNumber, + score: result.score, + }) + } + return (
  • setRef?.(el, index)} + onClick={handleClick} onKeyDown={(e) => { if (e.key === 'Enter') { + handleClick() + // Navigate to the result URL window.location.href = result.url } else { // Type mismatch: event is from anchor but handler expects HTMLLIElement diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts index ad302a11c..44ef05335 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts @@ -1,3 +1,11 @@ +import { + ATTR_SEARCH_QUERY, + ATTR_SEARCH_PAGE, + ATTR_SEARCH_RESULTS_TOTAL, + ATTR_SEARCH_RESULTS_COUNT, + ATTR_SEARCH_PAGE_COUNT, +} from '../../../telemetry/semconv' +import { traceSpan } from '../../../telemetry/tracing' import { createApiErrorFromResponse, shouldRetry } from '../errorHandling' import { ApiError } from '../errorHandling' import { usePageNumber, useSearchTerm } from './search.store' @@ -74,23 +82,50 @@ export const useSearchQuery = () => { { searchTerm: debouncedSearchTerm.toLowerCase(), pageNumber }, ], queryFn: async ({ signal }) => { + // Don't create span for empty searches if (!debouncedSearchTerm || debouncedSearchTerm.length < 1) { - return SearchResponse.parse({ results: [], totalResults: 0 }) + return SearchResponse.parse({ + results: [], + totalResults: 0, + }) } - const params = new URLSearchParams({ - q: debouncedSearchTerm, - page: pageNumber.toString(), - }) - const response = await fetch( - '/docs/_api/v1/search?' + params.toString(), - { signal } - ) - if (!response.ok) { - throw await createApiErrorFromResponse(response) - } - const data = await response.json() - return SearchResponse.parse(data) + return traceSpan('execute search', async (span) => { + // Track frontend search (even if backend response is cached by CloudFront) + span.setAttribute(ATTR_SEARCH_QUERY, debouncedSearchTerm) + span.setAttribute(ATTR_SEARCH_PAGE, pageNumber) + + const params = new URLSearchParams({ + q: debouncedSearchTerm, + page: pageNumber.toString(), + }) + + const response = await fetch( + '/docs/_api/v1/search?' + params.toString(), + { signal } + ) + if (!response.ok) { + throw await createApiErrorFromResponse(response) + } + const data = await response.json() + const searchResponse = SearchResponse.parse(data) + + // Add result metrics to span + span.setAttribute( + ATTR_SEARCH_RESULTS_TOTAL, + searchResponse.totalResults + ) + span.setAttribute( + ATTR_SEARCH_RESULTS_COUNT, + searchResponse.results.length + ) + span.setAttribute( + ATTR_SEARCH_PAGE_COUNT, + searchResponse.pageCount + ) + + return searchResponse + }) }, enabled: shouldEnable, refetchOnWindowFocus: false, diff --git a/src/Elastic.Documentation.Site/Elastic.Documentation.Site.csproj b/src/Elastic.Documentation.Site/Elastic.Documentation.Site.csproj index f7e56843d..1ce6c1365 100644 --- a/src/Elastic.Documentation.Site/Elastic.Documentation.Site.csproj +++ b/src/Elastic.Documentation.Site/Elastic.Documentation.Site.csproj @@ -40,9 +40,14 @@ - - + + + diff --git a/src/Elastic.Documentation.Site/FileProviders/Preloader.cs b/src/Elastic.Documentation.Site/FileProviders/Preloader.cs index 1364995a8..743218dbb 100644 --- a/src/Elastic.Documentation.Site/FileProviders/Preloader.cs +++ b/src/Elastic.Documentation.Site/FileProviders/Preloader.cs @@ -11,6 +11,9 @@ public static partial class FontPreloader { private static IReadOnlyCollection? FontUriCache; + // For development: clear cache when needed + public static void ClearCache() => FontUriCache = null; + public static async Task> GetFontUrisAsync(string? urlPrefix) => FontUriCache ??= await LoadFontUrisAsync(urlPrefix); private static async Task> LoadFontUrisAsync(string? urlPrefix) { diff --git a/src/Elastic.Documentation.Site/Layout/_Head.cshtml b/src/Elastic.Documentation.Site/Layout/_Head.cshtml index 99b793339..bdd5b402f 100644 --- a/src/Elastic.Documentation.Site/Layout/_Head.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_Head.cshtml @@ -8,7 +8,6 @@ } - @if (Model.CanonicalBaseUrl is not null) { diff --git a/src/Elastic.Documentation.Site/eslint.config.mjs b/src/Elastic.Documentation.Site/eslint.config.mjs index 8824c6dad..91e3366b2 100644 --- a/src/Elastic.Documentation.Site/eslint.config.mjs +++ b/src/Elastic.Documentation.Site/eslint.config.mjs @@ -16,4 +16,22 @@ export default defineConfig([ extends: ['js/recommended'], }, tseslint.configs.recommended, + { + files: ['**/*.{js,mjs,cjs,ts}'], + rules: { + 'no-console': [ + 'error', + { + allow: ['warn', 'error'], + }, + ], + }, + }, + { + // Allow console.log in synthetics config (test configuration file) + files: ['synthetics/**/*.ts'], + rules: { + 'no-console': 'off', + }, + }, ]) diff --git a/src/Elastic.Documentation.Site/package-lock.json b/src/Elastic.Documentation.Site/package-lock.json index ad105ff7f..4558dcc9b 100644 --- a/src/Elastic.Documentation.Site/package-lock.json +++ b/src/Elastic.Documentation.Site/package-lock.json @@ -13,6 +13,19 @@ "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@microsoft/fetch-event-source": "2.0.1", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.208.0", + "@opentelemetry/context-zone": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.208.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-fetch": "^0.208.0", + "@opentelemetry/otlp-exporter-base": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-logs": "^0.208.0", + "@opentelemetry/sdk-trace-web": "^2.2.0", + "@opentelemetry/semantic-conventions": "^1.38.0", "@r2wc/react-to-web-component": "2.1.0", "@tanstack/react-query": "^5.90.6", "@uidotdev/usehooks": "2.4.1", @@ -39,6 +52,7 @@ "@elastic/synthetics": "1.19.0", "@eslint/js": "9.39.0", "@parcel/reporter-bundle-analyzer": "2.16.0", + "@parcel/transformer-typescript-tsc": "^2.16.1", "@tailwindcss/postcss": "4.1.16", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.0", @@ -63,6 +77,7 @@ "react": "18.3.1", "react-dom": "18.3.1", "text-diff": "1.0.1", + "typescript": "^5.9.3", "typescript-eslint": "8.46.3", "wait-on": "9.0.1" } @@ -4464,6 +4479,269 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", + "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-zone": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-zone/-/context-zone-2.2.0.tgz", + "integrity": "sha512-Wq0nUuRyVBmXIeISO1Sg9yTz+mUypCGjwGHSPR9iaY4f+n+F728+5hh85lko6fnm/oJAiKhmSmvvH/o8PhSUnw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-zone-peer-dep": "2.2.0", + "zone.js": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + } + }, + "node_modules/@opentelemetry/context-zone-peer-dep": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-zone-peer-dep/-/context-zone-peer-dep-2.2.0.tgz", + "integrity": "sha512-/jSqc9MDpI7abRYNoM77G7xrJL8RhvOoQzmWg4Exj642NN1+ZwsqW0EODgaR99/w06nS2IGgY7AJRt5eZY/6QQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0", + "zone.js": "^0.10.2 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz", + "integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-exporter-base": "0.208.0", + "@opentelemetry/otlp-transformer": "0.208.0", + "@opentelemetry/sdk-logs": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.208.0.tgz", + "integrity": "sha512-jbzDw1q+BkwKFq9yxhjAJ9rjKldbt5AgIy1gmEIJjEV/WRxQ3B6HcLVkwbjJ3RcMif86BDNKR846KJ0tY0aOJA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-exporter-base": "0.208.0", + "@opentelemetry/otlp-transformer": "0.208.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/sdk-trace-base": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", + "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fetch": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fetch/-/instrumentation-fetch-0.208.0.tgz", + "integrity": "sha512-zgStoUfNF1xH9bCq539k1aeieKxPiAvBo5gKipQ9fIt+eJsFvqGcSzrrDX+OYgpIPW/IVNgWBoOw6zVmKwgNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/instrumentation": "0.208.0", + "@opentelemetry/sdk-trace-web": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz", + "integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-transformer": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz", + "integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/sdk-logs": "0.208.0", + "@opentelemetry/sdk-metrics": "2.2.0", + "@opentelemetry/sdk-trace-base": "2.2.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz", + "integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", + "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", + "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.2.0.tgz", + "integrity": "sha512-x/LHsDBO3kfqaFx5qSzBljJ5QHsRXrvS4MybBDy1k7Svidb8ZyIPudWVzj3s5LpPkYZIgi9e+7tdsNCnptoelw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/sdk-trace-base": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", + "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@parcel/bundler-default": { "version": "2.16.0", "resolved": "https://registry.npmjs.org/@parcel/bundler-default/-/bundler-default-2.16.0.tgz", @@ -5704,181 +5982,810 @@ "semver": "^7.7.1" }, "engines": { - "node": ">= 16.0.0", - "parcel": "2.16.0" + "node": ">= 16.0.0", + "parcel": "2.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "peerDependencies": { + "@parcel/core": "^2.16.0" + } + }, + "node_modules/@parcel/transformer-js/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@parcel/transformer-json": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@parcel/transformer-json/-/transformer-json-2.16.0.tgz", + "integrity": "sha512-qX6Zg+j7HezY+W2TNjJ+VPUsIviNdTuMn39W9M0YEd0WLKh0x7XD4oprVivvgD0Vbm04FUcTQEN1jAF3CAVeGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/plugin": "2.16.0", + "json5": "^2.2.3" + }, + "engines": { + "node": ">= 16.0.0", + "parcel": "2.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-node": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@parcel/transformer-node/-/transformer-node-2.16.0.tgz", + "integrity": "sha512-Mavmjj6SfP0Lhu751G47EFtExZIJyD+V2C5PzdATTaT+cw0MzQgfLH8s4p0CI27MAuyFesm8WTA0lgUtcfzMSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/plugin": "2.16.0" + }, + "engines": { + "node": ">= 16.0.0", + "parcel": "2.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-postcss": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@parcel/transformer-postcss/-/transformer-postcss-2.16.0.tgz", + "integrity": "sha512-h+Qnn49UE5RywpuXMHN8Iufjvc7MMqHQc0sPNvwoLBXJXJcb3ul7WEY+DGXs90KsUY1B6JAqKtz9+pzqXZMwIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/diagnostic": "2.16.0", + "@parcel/plugin": "2.16.0", + "@parcel/rust": "2.16.0", + "@parcel/utils": "2.16.0", + "clone": "^2.1.2", + "nullthrows": "^1.1.1", + "postcss-value-parser": "^4.2.0", + "semver": "^7.7.1" + }, + "engines": { + "node": ">= 16.0.0", + "parcel": "2.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-postcss/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@parcel/transformer-posthtml": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@parcel/transformer-posthtml/-/transformer-posthtml-2.16.0.tgz", + "integrity": "sha512-mvHQNzFO1xPq+/7McjxF7+Zb2zAgksNbSXKi8/OuMRiNO3eDD/r1jWRWKNQZHWUkSx/vS7JJ5Y1ACI5INLxWww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/plugin": "2.16.0", + "@parcel/utils": "2.16.0" + }, + "engines": { + "node": ">= 16.0.0", + "parcel": "2.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-raw": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@parcel/transformer-raw/-/transformer-raw-2.16.0.tgz", + "integrity": "sha512-LJXwH2rQAo6mOU6uG0IGQIN7KLC2sS8bl6aqf1YMcKk6ZEvylQkP0hUvRYja2IRzPoxjpdcAP5WC4e/Z8S1Vzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/plugin": "2.16.0" + }, + "engines": { + "node": ">= 16.0.0", + "parcel": "2.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-react-refresh-wrap": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@parcel/transformer-react-refresh-wrap/-/transformer-react-refresh-wrap-2.16.0.tgz", + "integrity": "sha512-s6O5oJ0pUtZey6unI0mz2WIOpAVLCn5+hlou4YH7FXOiMvSJ2PU2rakk+EZk6K/R+TStYM0hQKSwJkiiN0m7Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/error-overlay": "2.16.0", + "@parcel/plugin": "2.16.0", + "@parcel/utils": "2.16.0", + "react-refresh": "^0.16.0" + }, + "engines": { + "node": ">= 16.0.0", + "parcel": "2.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-svg": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@parcel/transformer-svg/-/transformer-svg-2.16.0.tgz", + "integrity": "sha512-c4KpIqqbsvsh/ZxLTo0d7/IEVa/jR/+LZ1kFzBWXKvMBzbvqo63J6s3VGk61gPFV9JkSW3UI5LAMbJn/HDXycw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/diagnostic": "2.16.0", + "@parcel/plugin": "2.16.0", + "@parcel/rust": "2.16.0" + }, + "engines": { + "node": ">= 16.0.0", + "parcel": "2.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-typescript-tsc": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/transformer-typescript-tsc/-/transformer-typescript-tsc-2.16.1.tgz", + "integrity": "sha512-aItrrBNXzRcdI+YVQP50eKLe8/zlw8t1x70Fu1fK3GjJvN1/wsR+s957agqUPCESt+1CyyLAJsErKPJPiJMIGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/plugin": "2.16.1", + "@parcel/source-map": "^2.1.1", + "@parcel/ts-utils": "2.16.1" + }, + "engines": { + "node": ">= 16.0.0", + "parcel": "^2.16.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "peerDependencies": { + "typescript": ">=3.0.0" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/cache": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/cache/-/cache-2.16.1.tgz", + "integrity": "sha512-qDlHQQ7RDfSi5MBnuFGCfQYiQQomsA5aZLntO5MCRD62VnMf9qz/RrCqpGFGOooljMoUaeVl0Q8ARvorRJJi8w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@parcel/fs": "2.16.1", + "@parcel/logger": "2.16.1", + "@parcel/utils": "2.16.1", + "lmdb": "2.8.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "peerDependencies": { + "@parcel/core": "^2.16.1" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/codeframe": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/codeframe/-/codeframe-2.16.1.tgz", + "integrity": "sha512-KLy9Fvf37SX6/wek2SUPw8A/W0kChcNXPUNeCIYWUFI4USAZ5KvesXS5RHUnrJTaR0XzD0Qia+MFJPgp6kuazQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">= 16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/core": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/core/-/core-2.16.1.tgz", + "integrity": "sha512-tza8oKYaPopGBwroGJKv7BrTg1lxTycS7SANIizxMB9FxDsAkq4vPny5/KHpFBcW3UTCGBvvNAG1oaVzeWF5Pg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@mischnic/json-sourcemap": "^0.1.1", + "@parcel/cache": "2.16.1", + "@parcel/diagnostic": "2.16.1", + "@parcel/events": "2.16.1", + "@parcel/feature-flags": "2.16.1", + "@parcel/fs": "2.16.1", + "@parcel/graph": "3.6.1", + "@parcel/logger": "2.16.1", + "@parcel/package-manager": "2.16.1", + "@parcel/plugin": "2.16.1", + "@parcel/profiler": "2.16.1", + "@parcel/rust": "2.16.1", + "@parcel/source-map": "^2.1.1", + "@parcel/types": "2.16.1", + "@parcel/utils": "2.16.1", + "@parcel/workers": "2.16.1", + "base-x": "^3.0.11", + "browserslist": "^4.24.5", + "clone": "^2.1.2", + "dotenv": "^16.5.0", + "dotenv-expand": "^11.0.7", + "json5": "^2.2.3", + "msgpackr": "^1.11.2", + "nullthrows": "^1.1.1", + "semver": "^7.7.1" + }, + "engines": { + "node": ">= 16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/diagnostic": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/diagnostic/-/diagnostic-2.16.1.tgz", + "integrity": "sha512-PJl7/QGsPboAMVFZId31iGMMY70AllZNOtYka9rTZRjTiBhZw4VrAG/RdqqKzjVuL6fZhurmfcwWzj+3gx8ccg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mischnic/json-sourcemap": "^0.1.1", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">= 16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/events": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/events/-/events-2.16.1.tgz", + "integrity": "sha512-+U7Trb2W8fm8w/OjwQpWN/Tepiwim/YNXuyPrhikFnsrg6QDdDTD/8/km4ah8Bzr0u4hIrn1k32InwDMCF5sig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/feature-flags": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/feature-flags/-/feature-flags-2.16.1.tgz", + "integrity": "sha512-MY/z4gKZWk0MKvP+gpU42kiE9W4f9NM1fSCa1OcdqF7IUJDDM41CDJ9rbwSQrroDddIViaNzsLo7aSYVI/C7aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/fs": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/fs/-/fs-2.16.1.tgz", + "integrity": "sha512-/akyrCaurd8rfgXuT6tDAK6I1JfW56TFJmzfIwuFSPbRy3YVu4JKN1g2PShpOLPdnqfWZNCcsd+yuuMFVhA2HA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@parcel/feature-flags": "2.16.1", + "@parcel/rust": "2.16.1", + "@parcel/types-internal": "2.16.1", + "@parcel/utils": "2.16.1", + "@parcel/watcher": "^2.0.7", + "@parcel/workers": "2.16.1" + }, + "engines": { + "node": ">= 16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "peerDependencies": { + "@parcel/core": "^2.16.1" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/graph": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@parcel/graph/-/graph-3.6.1.tgz", + "integrity": "sha512-82sjbjrSPK5BXH0tb65tQl/qvo/b2vsyA5F6z3SaQ/c3A5bmv5RxTvse1AgOb0St0lZ7ALaZibj1qZFBUyjdqw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@parcel/feature-flags": "2.16.1", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">= 16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/logger": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/logger/-/logger-2.16.1.tgz", + "integrity": "sha512-w9Qpp5S79fqn6nh/VqVYG4kCbIeW45zdPvYJMFgE90zhBRLrOnqw06cRZQdKj24C7/kdqOFFbrJ3B5uTsYeS0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/diagnostic": "2.16.1", + "@parcel/events": "2.16.1" + }, + "engines": { + "node": ">= 16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/markdown-ansi": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/markdown-ansi/-/markdown-ansi-2.16.1.tgz", + "integrity": "sha512-4Qww9KkGrVrY/JyD2NtrdUmyufKOqGg3t6hkE4UqQBPb+GZd+TQi6i1mjWvOE6r9AF53x5PAZZ13f/HfllU2qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">= 16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/node-resolver-core": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@parcel/node-resolver-core/-/node-resolver-core-3.7.1.tgz", + "integrity": "sha512-xY+mzz1a5L22HvwkCHtt1fRZa8pD8znXLB8NLnqdu/xa7FGwWNgA2ukFPSlNGwwI5aw3jQylERP8Mr6/qLsefQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@mischnic/json-sourcemap": "^0.1.1", + "@parcel/diagnostic": "2.16.1", + "@parcel/fs": "2.16.1", + "@parcel/rust": "2.16.1", + "@parcel/utils": "2.16.1", + "nullthrows": "^1.1.1", + "semver": "^7.7.1" + }, + "engines": { + "node": ">= 16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/package-manager": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/package-manager/-/package-manager-2.16.1.tgz", + "integrity": "sha512-HDMT0+L7kMBG+YgkxaNv/1nobFRgygte9e0QuYiSVMngdbYvXw9Yy8tEDeWEAOKWs0rGtPXJD6k9gP8/Aa3VQw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@parcel/diagnostic": "2.16.1", + "@parcel/fs": "2.16.1", + "@parcel/logger": "2.16.1", + "@parcel/node-resolver-core": "3.7.1", + "@parcel/types": "2.16.1", + "@parcel/utils": "2.16.1", + "@parcel/workers": "2.16.1", + "@swc/core": "^1.11.24", + "semver": "^7.7.1" + }, + "engines": { + "node": ">= 16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "peerDependencies": { + "@parcel/core": "^2.16.1" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/plugin": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/plugin/-/plugin-2.16.1.tgz", + "integrity": "sha512-/5hdgMFjd4pRZelfzWVAEWEH51qCHGB6I3z4mV3i8Teh0zsOgoHJrn1t+sVYkhKPDOMs16XAkx2iCMvEcktDrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/types": "2.16.1" + }, + "engines": { + "node": ">= 16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/profiler": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/profiler/-/profiler-2.16.1.tgz", + "integrity": "sha512-9VKswpixK5CggxqoEoThiusnRbqU48QIWwmGQhaTV9iBYi9m/LhEYUoTa8K/KQ70yJknghMMNc1JfAvt2bfh5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/diagnostic": "2.16.1", + "@parcel/events": "2.16.1", + "@parcel/types-internal": "2.16.1", + "chrome-trace-event": "^1.0.2" + }, + "engines": { + "node": ">= 16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/rust": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/rust/-/rust-2.16.1.tgz", + "integrity": "sha512-lQkf14MLKZSY/P8j1lrOgFvMCt95dO+VdXIIM2aHjbxnzYSIGgHIt2XDVtKULE+DexaYZbleA0tTnX8AABUIyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/rust-darwin-arm64": "2.16.1", + "@parcel/rust-darwin-x64": "2.16.1", + "@parcel/rust-linux-arm-gnueabihf": "2.16.1", + "@parcel/rust-linux-arm64-gnu": "2.16.1", + "@parcel/rust-linux-arm64-musl": "2.16.1", + "@parcel/rust-linux-x64-gnu": "2.16.1", + "@parcel/rust-linux-x64-musl": "2.16.1", + "@parcel/rust-win32-x64-msvc": "2.16.1" + }, + "peerDependencies": { + "napi-wasm": "^1.1.2" + }, + "peerDependenciesMeta": { + "napi-wasm": { + "optional": true + } + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/rust-darwin-arm64": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/rust-darwin-arm64/-/rust-darwin-arm64-2.16.1.tgz", + "integrity": "sha512-6J1pnznHYzH1TOQbDZmbGa6bXNW+KXbD+XIihvQOid42DLGJNXRmwMmCU3en/759lF/pfmzmR7sm6wPKaKGfbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/rust-darwin-x64": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/rust-darwin-x64/-/rust-darwin-x64-2.16.1.tgz", + "integrity": "sha512-NDZpxleSeJ0yPx4OobDcj+z5x6RzsWmuA1RXBDuCKhf2kyXKP3+kfmrQew/7Q0r9uKA5pqCIw0W4eFqy4IoqIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/rust-linux-arm-gnueabihf": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/rust-linux-arm-gnueabihf/-/rust-linux-arm-gnueabihf-2.16.1.tgz", + "integrity": "sha512-xLLcbMP38ya8/z5esp3ypN2htxO9AsY4uQqF2rigIUZ2abQwL4MPKxfVZtrExWdcrcWiFUbiwn3+GKu/0M9Yow==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/rust-linux-arm64-gnu": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/rust-linux-arm64-gnu/-/rust-linux-arm64-gnu-2.16.1.tgz", + "integrity": "sha512-asZlimUq1wBmj2PDcoBSKD1SJvcLf1mXTcYGojOsA3dqkOOz7fGz7oubqZYn6IM+02cUDO4ekH+YBV6Eo7XlTg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - }, - "peerDependencies": { - "@parcel/core": "^2.16.0" } }, - "node_modules/@parcel/transformer-js/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/rust-linux-arm64-musl": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/rust-linux-arm64-musl/-/rust-linux-arm64-musl-2.16.1.tgz", + "integrity": "sha512-japSgrHYDD+uNHQ8TEdEhpiWu0zWMVBE48W3HJ5FKkwUOY51whZa8w0lhYW88ykUDYtEEd1ipvflv0fSDFY1jw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/transformer-json": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@parcel/transformer-json/-/transformer-json-2.16.0.tgz", - "integrity": "sha512-qX6Zg+j7HezY+W2TNjJ+VPUsIviNdTuMn39W9M0YEd0WLKh0x7XD4oprVivvgD0Vbm04FUcTQEN1jAF3CAVeGw==", + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/rust-linux-x64-gnu": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/rust-linux-x64-gnu/-/rust-linux-x64-gnu-2.16.1.tgz", + "integrity": "sha512-A2LHDou7QDsKn3qlE+DHTBFqnjk0Hy1dhVEJgPgvW4N0XMa4x2JEcnLI9oFZ4KDXyMLGs0H6/smZ88zSdFoF3w==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@parcel/plugin": "2.16.0", - "json5": "^2.2.3" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 16.0.0", - "parcel": "2.16.0" + "node": ">= 10" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/transformer-node": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@parcel/transformer-node/-/transformer-node-2.16.0.tgz", - "integrity": "sha512-Mavmjj6SfP0Lhu751G47EFtExZIJyD+V2C5PzdATTaT+cw0MzQgfLH8s4p0CI27MAuyFesm8WTA0lgUtcfzMSw==", + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/rust-linux-x64-musl": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/rust-linux-x64-musl/-/rust-linux-x64-musl-2.16.1.tgz", + "integrity": "sha512-C+WgGbmIV1XxXUgNJdXpfZazqizYBvy7aesh8Z74QrlY99an/puQufd4kSbvwySN5iMGPSpN0VlyAUjDZLv9rQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@parcel/plugin": "2.16.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 16.0.0", - "parcel": "2.16.0" + "node": ">= 10" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/transformer-postcss": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@parcel/transformer-postcss/-/transformer-postcss-2.16.0.tgz", - "integrity": "sha512-h+Qnn49UE5RywpuXMHN8Iufjvc7MMqHQc0sPNvwoLBXJXJcb3ul7WEY+DGXs90KsUY1B6JAqKtz9+pzqXZMwIg==", + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/rust-win32-x64-msvc": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/rust-win32-x64-msvc/-/rust-win32-x64-msvc-2.16.1.tgz", + "integrity": "sha512-m8LoaBJfw5nv/4elM/jNNhWL5/HqBHNQnrbnN89e8sxn4L/zv9bPoXqHOuZglXwyB5velw1MGonX9Be/aK00ag==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@parcel/diagnostic": "2.16.0", - "@parcel/plugin": "2.16.0", - "@parcel/rust": "2.16.0", - "@parcel/utils": "2.16.0", - "clone": "^2.1.2", - "nullthrows": "^1.1.1", - "postcss-value-parser": "^4.2.0", - "semver": "^7.7.1" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 16.0.0", - "parcel": "2.16.0" + "node": ">= 10" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/transformer-postcss/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/types": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/types/-/types-2.16.1.tgz", + "integrity": "sha512-RFeomuzV/0Ze0jyzzx0u/eB4bXX6ISxrARA3k/3c7MQ+jaoY67+ELd8FwPV6ZmLqvvYIFdGiCZl6ascCABKwgg==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" + "license": "MIT", + "dependencies": { + "@parcel/types-internal": "2.16.1", + "@parcel/workers": "2.16.1" } }, - "node_modules/@parcel/transformer-posthtml": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@parcel/transformer-posthtml/-/transformer-posthtml-2.16.0.tgz", - "integrity": "sha512-mvHQNzFO1xPq+/7McjxF7+Zb2zAgksNbSXKi8/OuMRiNO3eDD/r1jWRWKNQZHWUkSx/vS7JJ5Y1ACI5INLxWww==", + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/types-internal": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/types-internal/-/types-internal-2.16.1.tgz", + "integrity": "sha512-HVCHm0uFyJMsu30bAfm/pd0RNsXRWX0mUXaDHzGJRZ2Yer53JA6elRwkgrPz1KosBA+OuNU/G8atXfCxPMXdKw==", "dev": true, "license": "MIT", "dependencies": { - "@parcel/plugin": "2.16.0", - "@parcel/utils": "2.16.0" - }, - "engines": { - "node": ">= 16.0.0", - "parcel": "2.16.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "@parcel/diagnostic": "2.16.1", + "@parcel/feature-flags": "2.16.1", + "@parcel/source-map": "^2.1.1", + "utility-types": "^3.11.0" } }, - "node_modules/@parcel/transformer-raw": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@parcel/transformer-raw/-/transformer-raw-2.16.0.tgz", - "integrity": "sha512-LJXwH2rQAo6mOU6uG0IGQIN7KLC2sS8bl6aqf1YMcKk6ZEvylQkP0hUvRYja2IRzPoxjpdcAP5WC4e/Z8S1Vzg==", + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/utils": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/utils/-/utils-2.16.1.tgz", + "integrity": "sha512-aoY6SCfAY7X6L39PFOsWNNcAobmJr4AJEgco+PJ2UAPFiHhkBZfUofXCwna5GHH5uqXZx6u3rAHiCUrM3bEDXg==", "dev": true, "license": "MIT", "dependencies": { - "@parcel/plugin": "2.16.0" + "@parcel/codeframe": "2.16.1", + "@parcel/diagnostic": "2.16.1", + "@parcel/logger": "2.16.1", + "@parcel/markdown-ansi": "2.16.1", + "@parcel/rust": "2.16.1", + "@parcel/source-map": "^2.1.1", + "chalk": "^4.1.2", + "nullthrows": "^1.1.1" }, "engines": { - "node": ">= 16.0.0", - "parcel": "2.16.0" + "node": ">= 16.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/transformer-react-refresh-wrap": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@parcel/transformer-react-refresh-wrap/-/transformer-react-refresh-wrap-2.16.0.tgz", - "integrity": "sha512-s6O5oJ0pUtZey6unI0mz2WIOpAVLCn5+hlou4YH7FXOiMvSJ2PU2rakk+EZk6K/R+TStYM0hQKSwJkiiN0m7Rg==", + "node_modules/@parcel/transformer-typescript-tsc/node_modules/@parcel/workers": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/workers/-/workers-2.16.1.tgz", + "integrity": "sha512-yEUAjBrSgo5MYAAQbncYbw1m9WrNiJs+xKdfdHNUrOHlT7G+v62HJAZJWJsvyGQBE2nchSO+bEPgv+kxAF8mIA==", "dev": true, "license": "MIT", "dependencies": { - "@parcel/error-overlay": "2.16.0", - "@parcel/plugin": "2.16.0", - "@parcel/utils": "2.16.0", - "react-refresh": "^0.16.0" + "@parcel/diagnostic": "2.16.1", + "@parcel/logger": "2.16.1", + "@parcel/profiler": "2.16.1", + "@parcel/types-internal": "2.16.1", + "@parcel/utils": "2.16.1", + "nullthrows": "^1.1.1" }, "engines": { - "node": ">= 16.0.0", - "parcel": "2.16.0" + "node": ">= 16.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" + }, + "peerDependencies": { + "@parcel/core": "^2.16.1" } }, - "node_modules/@parcel/transformer-svg": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@parcel/transformer-svg/-/transformer-svg-2.16.0.tgz", - "integrity": "sha512-c4KpIqqbsvsh/ZxLTo0d7/IEVa/jR/+LZ1kFzBWXKvMBzbvqo63J6s3VGk61gPFV9JkSW3UI5LAMbJn/HDXycw==", + "node_modules/@parcel/transformer-typescript-tsc/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@parcel/ts-utils": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@parcel/ts-utils/-/ts-utils-2.16.1.tgz", + "integrity": "sha512-UuH60I/cGOy/b++Zx8h4qI2V8DXlmMyTYcUPi+x5JHT6L1VZBWohsz6qlP+Iek4BTMMs/g52Q57q++3eLD8Rdw==", "dev": true, "license": "MIT", "dependencies": { - "@parcel/diagnostic": "2.16.0", - "@parcel/plugin": "2.16.0", - "@parcel/rust": "2.16.0" + "nullthrows": "^1.1.1" }, "engines": { - "node": ">= 16.0.0", - "parcel": "2.16.0" + "node": ">= 16.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" + }, + "peerDependencies": { + "typescript": ">=3.0.0" } }, "node_modules/@parcel/types": { @@ -6296,6 +7203,70 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@r2wc/core": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@r2wc/core/-/core-1.3.0.tgz", @@ -7276,7 +8247,6 @@ "version": "24.1.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", - "dev": true, "dependencies": { "undici-types": "~7.8.0" } @@ -7970,7 +8940,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -7978,6 +8947,15 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -10489,6 +11467,24 @@ "node": ">=4" } }, + "node_modules/import-in-the-middle": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.0.tgz", + "integrity": "sha512-yNZhyQYqXpkT0AKq3F3KLasUSK4fHvebNH5hOsKQw2dhGSALvQ4U0BqUc5suziKvydO5u5hgN2hy1RJaho8U5A==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "node_modules/import-in-the-middle/node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "license": "MIT" + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -12495,6 +13491,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -12757,6 +13759,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -13586,6 +14594,30 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -14217,6 +15249,19 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -15102,10 +16147,10 @@ "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==" }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "peer": true, + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15191,8 +16236,7 @@ "node_modules/undici-types": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", - "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", - "dev": true + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==" }, "node_modules/unherit": { "version": "1.1.3", @@ -15938,6 +16982,12 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zone.js": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", + "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", + "license": "MIT" + }, "node_modules/zustand": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", diff --git a/src/Elastic.Documentation.Site/package.json b/src/Elastic.Documentation.Site/package.json index 543c4fa62..df0079a42 100644 --- a/src/Elastic.Documentation.Site/package.json +++ b/src/Elastic.Documentation.Site/package.json @@ -25,10 +25,6 @@ "synthetics:push:edge": "DOCS_ENV=edge npm run synthetics:push -- --tags=\"env:edge\"" }, "targets": { - "customElements": { - "distDir": "_static", - "source": "Assets/custom-elements.ts" - }, "js": { "distDir": "_static", "source": "Assets/main.ts" @@ -38,6 +34,9 @@ "source": "Assets/styles.css" } }, + "alias": { + "@opentelemetry/otlp-exporter-base/browser-http": "@opentelemetry/otlp-exporter-base/build/esm/index-browser-http.js" + }, "repository": { "type": "git", "url": "https://github.com/elastic/docs-builder.git" @@ -75,6 +74,7 @@ "react": "18.3.1", "react-dom": "18.3.1", "text-diff": "1.0.1", + "typescript": "^5.9.3", "typescript-eslint": "8.46.3", "wait-on": "9.0.1" }, @@ -87,6 +87,19 @@ "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@microsoft/fetch-event-source": "2.0.1", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.208.0", + "@opentelemetry/context-zone": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.208.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-fetch": "^0.208.0", + "@opentelemetry/otlp-exporter-base": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-logs": "^0.208.0", + "@opentelemetry/sdk-trace-web": "^2.2.0", + "@opentelemetry/semantic-conventions": "^1.38.0", "@r2wc/react-to-web-component": "2.1.0", "@tanstack/react-query": "^5.90.6", "@uidotdev/usehooks": "2.4.1", diff --git a/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs index 4a31ac2e5..43555d174 100644 --- a/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs +++ b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs @@ -18,7 +18,7 @@ public class AskAiUsecase( public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx) { logger.LogInformation("Starting AskAI chat with {AgentProvider} and {AgentId}", streamTransformer.AgentProvider, streamTransformer.AgentId); - var activity = AskAiActivitySource.StartActivity($"chat ${streamTransformer.AgentProvider}", ActivityKind.Client); + var activity = AskAiActivitySource.StartActivity($"chat {streamTransformer.AgentProvider}", ActivityKind.Client); _ = activity?.SetTag("gen_ai.operation.name", "chat"); _ = activity?.SetTag("gen_ai.provider.name", streamTransformer.AgentProvider); // agent-builder or llm-gateway _ = activity?.SetTag("gen_ai.agent.id", streamTransformer.AgentId); // docs-agent or docs_assistant diff --git a/src/api/Elastic.Documentation.Api.Core/Search/SearchUsecase.cs b/src/api/Elastic.Documentation.Api.Core/Search/SearchUsecase.cs index 20d6ed6f9..4c696c820 100644 --- a/src/api/Elastic.Documentation.Api.Core/Search/SearchUsecase.cs +++ b/src/api/Elastic.Documentation.Api.Core/Search/SearchUsecase.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Diagnostics; using Microsoft.Extensions.Logging; namespace Elastic.Documentation.Api.Core.Search; diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/IOtlpGateway.cs b/src/api/Elastic.Documentation.Api.Core/Telemetry/IOtlpGateway.cs index ae2518af2..7cb0a67d8 100644 --- a/src/api/Elastic.Documentation.Api.Core/Telemetry/IOtlpGateway.cs +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/IOtlpGateway.cs @@ -16,8 +16,8 @@ public interface IOtlpGateway /// The raw OTLP payload stream /// Content-Type of the payload /// Cancellation token - /// HTTP status code and response content - Task<(int StatusCode, string? Content)> ForwardOtlp( + /// Result containing HTTP status code and response content + Task ForwardOtlp( OtlpSignalType signalType, Stream requestBody, string contentType, diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpForwardResult.cs b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpForwardResult.cs new file mode 100644 index 000000000..7a9b4a1d0 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpForwardResult.cs @@ -0,0 +1,26 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.Api.Core.Telemetry; + +/// +/// Result of forwarding OTLP telemetry to a collector. +/// +public record OtlpForwardResult +{ + /// + /// HTTP status code from the collector response. + /// + public required int StatusCode { get; init; } + + /// + /// Response content from the collector, if any. + /// + public string? Content { get; init; } + + /// + /// Whether the forward operation was successful (2xx status code). + /// + public bool IsSuccess => StatusCode is >= 200 and < 300; +} diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyOptions.cs b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyOptions.cs index 4872cb9b8..a14f48a05 100644 --- a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyOptions.cs +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyOptions.cs @@ -22,27 +22,29 @@ namespace Elastic.Documentation.Api.Core.Telemetry; /// /// The proxy will return 503 if the collector is not available. /// -public class OtlpProxyOptions +public class OtlpProxyOptions(IConfiguration configuration) { /// /// OTLP endpoint URL for the local ADOT collector. /// Defaults to localhost:4318 when running in Lambda with ADOT layer. /// - public string Endpoint { get; } + public string Endpoint { get; } = ResolveEndpoint(configuration); - public OtlpProxyOptions(IConfiguration configuration) + private static string ResolveEndpoint(IConfiguration configuration) { - // Check for explicit configuration override first (for tests or custom deployments) - var configEndpoint = configuration["OtlpProxy:Endpoint"]; - if (!string.IsNullOrEmpty(configEndpoint)) - { - Endpoint = configEndpoint; - return; - } + const string configKey = "OtlpProxy:Endpoint"; + const string envVarKey = "OTEL_EXPORTER_OTLP_ENDPOINT"; + const string defaultEndpoint = "http://localhost:4318"; - // Default to localhost:4318 - this is where ADOT Lambda Layer collector runs - // If ADOT layer is not present, the proxy will fail gracefully and return 503 - Endpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") - ?? "http://localhost:4318"; + // Priority 1: Explicit configuration (for tests or custom deployments) + if (!string.IsNullOrEmpty(configuration[configKey])) + return configuration[configKey]!; + + // Priority 2: Environment variable (ADOT Lambda Layer standard) + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envVarKey))) + return Environment.GetEnvironmentVariable(envVarKey)!; + + // Priority 3: Default (ADOT Lambda Layer collector) + return defaultEndpoint; } } diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyUsecase.cs b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyUsecase.cs index a0eb1fb9f..9c051a579 100644 --- a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyUsecase.cs +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyUsecase.cs @@ -21,14 +21,14 @@ public class OtlpProxyUsecase(IOtlpGateway gateway) /// The raw OTLP payload (JSON or protobuf) /// Content-Type header from the original request /// Cancellation token - /// HTTP status code and response content - public async Task<(int StatusCode, string? Content)> ProxyOtlp( + /// Result containing HTTP status code and response content + public async Task ProxyOtlp( OtlpSignalType signalType, Stream requestBody, string contentType, Cancel ctx = default) { - using var activity = ActivitySource.StartActivity("ProxyOtlp", ActivityKind.Client); + using var activity = ActivitySource.StartActivity("forward otlp", ActivityKind.Client); // Forward to gateway return await gateway.ForwardOtlp(signalType, requestBody, contentType, ctx); diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs index 6748a40b1..cdf53b3c9 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs @@ -37,7 +37,7 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) var kibanaUrl = await parameterProvider.GetParam("docs-kibana-url", false, ctx); var kibanaApiKey = await parameterProvider.GetParam("docs-kibana-apikey", true, ctx); - using var request = new HttpRequestMessage(HttpMethod.Post, + var request = new HttpRequestMessage(HttpMethod.Post, $"{kibanaUrl}/api/agent_builder/converse/async") { Content = new StringContent(requestBody, Encoding.UTF8, "application/json") @@ -45,7 +45,7 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) request.Headers.Add("kbn-xsrf", "true"); request.Headers.Authorization = new AuthenticationHeaderValue("ApiKey", kibanaApiKey); - using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx); + var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx); // Ensure the response is successful before streaming if (!response.IsSuccessStatusCode) diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs index 64e3c72ca..f7d1cdf70 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs @@ -25,7 +25,7 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) { var llmGatewayRequest = LlmGatewayRequest.CreateFromRequest(askAiRequest); var requestBody = JsonSerializer.Serialize(llmGatewayRequest, LlmGatewayContext.Default.LlmGatewayRequest); - using var request = new HttpRequestMessage(HttpMethod.Post, options.FunctionUrl) + var request = new HttpRequestMessage(HttpMethod.Post, options.FunctionUrl) { Content = new StringContent(requestBody, Encoding.UTF8, "application/json") }; @@ -37,7 +37,7 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) // Use HttpCompletionOption.ResponseHeadersRead to get headers immediately // This allows us to start streaming as soon as headers are received - using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx); + var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx); // Ensure the response is successful before streaming if (!response.IsSuccessStatusCode) diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/StreamTransformerBase.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/StreamTransformerBase.cs index 5913b3df2..3079d7a77 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/StreamTransformerBase.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/StreamTransformerBase.cs @@ -89,8 +89,9 @@ private async Task ProcessPipeAsync(PipeReader reader, PipeWriter writer, string _ = parentActivity?.SetTag("error.type", ex.GetType().Name); try { + // Complete writer first, then reader - but don't try to complete reader + // if the exception came from reading (would cause "read operation pending" error) await writer.CompleteAsync(ex); - await reader.CompleteAsync(ex); } catch (Exception completeEx) { @@ -103,7 +104,6 @@ private async Task ProcessPipeAsync(PipeReader reader, PipeWriter writer, string try { await writer.CompleteAsync(); - await reader.CompleteAsync(); } catch (Exception ex) { diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Telemetry/AdotOtlpGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Telemetry/AdotOtlpGateway.cs index 600af78e5..d2a573238 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Telemetry/AdotOtlpGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Telemetry/AdotOtlpGateway.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Net.Sockets; using Elastic.Documentation.Api.Core.Telemetry; using Microsoft.Extensions.Logging; @@ -19,7 +20,7 @@ public class AdotOtlpGateway( private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); /// - public async Task<(int StatusCode, string? Content)> ForwardOtlp( + public async Task ForwardOtlp( OtlpSignalType signalType, Stream requestBody, string contentType, @@ -27,22 +28,13 @@ public class AdotOtlpGateway( { try { - // Build the target URL: http://localhost:4318/v1/{signalType} - // Use ToStringFast(true) from generated enum extensions (returns Display name: "traces", "logs", "metrics") var targetUrl = $"{options.Endpoint.TrimEnd('/')}/v1/{signalType.ToStringFast(true)}"; - logger.LogDebug("Forwarding OTLP {SignalType} to ADOT collector at {TargetUrl}", signalType, targetUrl); using var request = new HttpRequestMessage(HttpMethod.Post, targetUrl); - - // Forward the content with the original content type request.Content = new StreamContent(requestBody); _ = request.Content.Headers.TryAddWithoutValidation("Content-Type", contentType); - // No need to add authentication headers - ADOT layer handles auth to backend - // Just forward the telemetry to the local collector - - // Forward to ADOT collector using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, ctx); var responseContent = response.Content.Headers.ContentLength > 0 ? await response.Content.ReadAsStringAsync(ctx) @@ -58,17 +50,43 @@ public class AdotOtlpGateway( logger.LogDebug("Successfully forwarded OTLP {SignalType} to ADOT collector", signalType); } - return ((int)response.StatusCode, responseContent); - } - catch (HttpRequestException ex) when (ex.Message.Contains("Connection refused") || ex.InnerException?.Message?.Contains("Connection refused") == true) - { - logger.LogError(ex, "Failed to connect to ADOT collector at {Endpoint}. Is ADOT Lambda Layer enabled?", options.Endpoint); - return (503, "ADOT collector not available. Ensure AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-instrument is set"); + return new OtlpForwardResult + { + StatusCode = (int)response.StatusCode, + Content = responseContent + }; } catch (Exception ex) { - logger.LogError(ex, "Error forwarding OTLP {SignalType}", signalType); - return (500, $"Error forwarding OTLP: {ex.Message}"); + var (statusCode, message) = MapExceptionToStatusCode(ex); + logger.LogError(ex, "Error forwarding OTLP {SignalType}: {ErrorMessage}", signalType, message); + return new OtlpForwardResult + { + StatusCode = statusCode, + Content = message + }; } } + + private static (int StatusCode, string Message) MapExceptionToStatusCode(Exception ex) => + ex switch + { + // Connection refused - downstream service not available + HttpRequestException { InnerException: SocketException { SocketErrorCode: SocketError.ConnectionRefused } } + => (503, "Telemetry collector unavailable"), + + // Timeout - gateway timeout + HttpRequestException { InnerException: SocketException { SocketErrorCode: SocketError.TimedOut } } + => (504, "Telemetry collector timeout"), + + TaskCanceledException or OperationCanceledException + => (504, "Request to telemetry collector timed out"), + + // Other HTTP/network errors - bad gateway + HttpRequestException + => (502, "Failed to communicate with telemetry collector"), + + // Unknown errors + _ => (500, $"Internal error: {ex.Message}") + }; } diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs b/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs index 1c605e4a4..e336f23b2 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs @@ -63,37 +63,27 @@ private static void MapOtlpProxyEndpoint(IEndpointRouteBuilder group) // Use /o/* to avoid adblocker detection (common blocklists target /otlp, /telemetry, etc.) var otlpGroup = group.MapGroup("/o"); - // Proxy endpoint for traces - // Frontend: POST /_api/v1/o/t → ADOT: POST localhost:4318/v1/traces - _ = otlpGroup.MapPost("/t", - async (HttpContext context, OtlpProxyUsecase proxyUsecase, Cancel ctx) => - { - var contentType = context.Request.ContentType ?? "application/json"; - var (statusCode, content) = await proxyUsecase.ProxyOtlp(OtlpSignalType.Traces, context.Request.Body, contentType, ctx); - return Results.Content(content ?? string.Empty, contentType, statusCode: statusCode); - }) - .DisableAntiforgery(); // Frontend requests won't have antiforgery tokens - - // Proxy endpoint for logs - // Frontend: POST /_api/v1/o/l → ADOT: POST localhost:4318/v1/logs - _ = otlpGroup.MapPost("/l", - async (HttpContext context, OtlpProxyUsecase proxyUsecase, Cancel ctx) => - { - var contentType = context.Request.ContentType ?? "application/json"; - var (statusCode, content) = await proxyUsecase.ProxyOtlp(OtlpSignalType.Logs, context.Request.Body, contentType, ctx); - return Results.Content(content ?? string.Empty, contentType, statusCode: statusCode); - }) - .DisableAntiforgery(); + MapOtlpSignalEndpoint(otlpGroup, "/t", OtlpSignalType.Traces); + MapOtlpSignalEndpoint(otlpGroup, "/l", OtlpSignalType.Logs); + MapOtlpSignalEndpoint(otlpGroup, "/m", OtlpSignalType.Metrics); + } - // Proxy endpoint for metrics - // Frontend: POST /_api/v1/o/m → ADOT: POST localhost:4318/v1/metrics - _ = otlpGroup.MapPost("/m", + private static void MapOtlpSignalEndpoint( + IEndpointRouteBuilder group, + string path, + OtlpSignalType signalType) => + group.MapPost(path, async (HttpContext context, OtlpProxyUsecase proxyUsecase, Cancel ctx) => { var contentType = context.Request.ContentType ?? "application/json"; - var (statusCode, content) = await proxyUsecase.ProxyOtlp(OtlpSignalType.Metrics, context.Request.Body, contentType, ctx); - return Results.Content(content ?? string.Empty, contentType, statusCode: statusCode); + var result = await proxyUsecase.ProxyOtlp( + signalType, + context.Request.Body, + contentType, + ctx); + return result.IsSuccess + ? Results.NoContent() + : Results.StatusCode(result.StatusCode); }) .DisableAntiforgery(); - } } diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs b/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs index 55ad64151..72860efb8 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs @@ -39,6 +39,14 @@ public static TracerProviderBuilder AddDocsApiTracing(this TracerProviderBuilder .AddSource(TelemetryConstants.OtlpProxySourceName) .AddAspNetCoreInstrumentation(aspNetCoreOptions => { + // Don't trace root API endpoint (health check) + aspNetCoreOptions.Filter = (httpContext) => + { + var path = httpContext.Request.Path.Value ?? string.Empty; + // Exclude root API path: /docs/_api/v1 + return path != "/docs/_api/v1"; + }; + // Enrich spans with custom attributes from HTTP context aspNetCoreOptions.EnrichWithHttpRequest = (activity, httpRequest) => { diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj index d319a74c5..32e364eba 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj @@ -14,7 +14,6 @@ - diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs index bcecdab1d..e60c8518c 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs @@ -68,7 +68,7 @@ public async Task OtlpProxyTracesEndpointForwardsToCorrectUrl() throw new Exception($"Test failed with {response.StatusCode}: {errorBody}"); } - response.StatusCode.Should().Be(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); capturedRequest.Should().NotBeNull(); capturedRequest!.RequestUri.Should().NotBeNull(); capturedRequest.RequestUri!.ToString().Should().Be("http://localhost:4318/v1/traces"); @@ -129,7 +129,7 @@ public async Task OtlpProxyLogsEndpointForwardsToCorrectUrl() using var response = await client.PostAsync("/docs/_api/v1/o/l", content, TestContext.Current.CancellationToken); // Assert - verify the enum ToStringFast() generates "logs" (lowercase) - response.StatusCode.Should().Be(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); capturedRequest.Should().NotBeNull(); capturedRequest!.RequestUri!.ToString().Should().Be("http://localhost:4318/v1/logs"); @@ -182,7 +182,7 @@ public async Task OtlpProxyMetricsEndpointForwardsToCorrectUrl() using var response = await client.PostAsync("/docs/_api/v1/o/m", content, TestContext.Current.CancellationToken); // Assert - verify the enum ToStringFast() generates "metrics" (lowercase) - response.StatusCode.Should().Be(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); capturedRequest.Should().NotBeNull(); capturedRequest!.RequestUri!.ToString().Should().Be("http://localhost:4318/v1/metrics"); @@ -221,8 +221,6 @@ public async Task OtlpProxyReturnsCollectorErrorStatusCode() // Assert - verify error responses are properly forwarded response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); - var responseBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - responseBody.Should().Contain("Service unavailable"); // Cleanup mock response mockResponse.Dispose(); From 94df2599751c0c1e2de2dc327eeded5287bbb109 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Sat, 22 Nov 2025 10:06:51 +0100 Subject: [PATCH 7/9] Apply suggestion from @reakaleek --- src/Elastic.Documentation.Site/eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Site/eslint.config.mjs b/src/Elastic.Documentation.Site/eslint.config.mjs index 91e3366b2..4c2b82072 100644 --- a/src/Elastic.Documentation.Site/eslint.config.mjs +++ b/src/Elastic.Documentation.Site/eslint.config.mjs @@ -17,7 +17,7 @@ export default defineConfig([ }, tseslint.configs.recommended, { - files: ['**/*.{js,mjs,cjs,ts}'], + files: ['**/*.{js,jsx,mjs,cjs,ts,tsx}'], rules: { 'no-console': [ 'error', From 2c48576e1812e83f0751252a0e011247e2b6ee4c Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Mon, 24 Nov 2025 10:11:11 +0100 Subject: [PATCH 8/9] Remove redundant custom attributes on FetchInstrumentation --- .../Assets/telemetry/instrumentation.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/telemetry/instrumentation.ts b/src/Elastic.Documentation.Site/Assets/telemetry/instrumentation.ts index e7de8876a..c923d007d 100644 --- a/src/Elastic.Documentation.Site/Assets/telemetry/instrumentation.ts +++ b/src/Elastic.Documentation.Site/Assets/telemetry/instrumentation.ts @@ -142,12 +142,6 @@ function registerFetchInstrumentation(): void { /_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) - } - }, }), ], }) From 32be9a58df37ef4886cad1b179cdfdfa236e67ea Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Mon, 24 Nov 2025 11:56:52 +0100 Subject: [PATCH 9/9] Use logs instead of traces for search_result_clicked events --- .../SearchResults/SearchResultsListItem.tsx | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx index 031d189c8..5c2a79c71 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx @@ -1,4 +1,5 @@ /** @jsxImportSource @emotion/react */ +import { logInfo } from '../../../../telemetry/logging' import { ATTR_SEARCH_QUERY, ATTR_SEARCH_RESULT_URL, @@ -20,7 +21,6 @@ import { EuiSpacer, } from '@elastic/eui' import { css } from '@emotion/react' -import { trace } from '@opentelemetry/api' import DOMPurify from 'dompurify' import { memo, useMemo } from 'react' @@ -33,22 +33,17 @@ function trackSearchResultClick(params: { pageNumber: number score: number }): void { - const tracer = trace.getTracer('docs-frontend') - const span = tracer.startSpan('click search_result') - - span.setAttribute(ATTR_SEARCH_QUERY, params.query) - span.setAttribute(ATTR_SEARCH_RESULT_URL, params.resultUrl) - span.setAttribute(ATTR_SEARCH_RESULT_TITLE, params.resultTitle) - span.setAttribute(ATTR_SEARCH_RESULT_POSITION, params.absolutePosition) - span.setAttribute( - ATTR_SEARCH_RESULT_POSITION_ON_PAGE, - params.positionOnPage - ) - span.setAttribute(ATTR_SEARCH_PAGE, params.pageNumber) - span.setAttribute(ATTR_SEARCH_RESULT_SCORE, params.score) - span.setAttribute(ATTR_EVENT_NAME, 'search_result_clicked') - span.setAttribute(ATTR_EVENT_CATEGORY, 'ui') - span.end() + logInfo('search_result_clicked', { + [ATTR_SEARCH_QUERY]: params.query, + [ATTR_SEARCH_RESULT_URL]: params.resultUrl, + [ATTR_SEARCH_RESULT_TITLE]: params.resultTitle, + [ATTR_SEARCH_RESULT_POSITION]: params.absolutePosition, + [ATTR_SEARCH_RESULT_POSITION_ON_PAGE]: params.positionOnPage, + [ATTR_SEARCH_PAGE]: params.pageNumber, + [ATTR_SEARCH_RESULT_SCORE]: params.score, + [ATTR_EVENT_NAME]: 'search_result_clicked', + [ATTR_EVENT_CATEGORY]: 'ui', + }) } interface SearchResultListItemProps {