Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<PackageVersion Include="AWSSDK.S3" Version="4.0.7.14" />
<PackageVersion Include="Elastic.OpenTelemetry" Version="1.1.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Telemetry.Abstractions" Version="10.0.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageVersion Include="Generator.Equals" Version="3.2.1" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers; buildtransitive" />
Expand All @@ -41,6 +42,7 @@
<PackageVersion Include="Microsoft.OpenApi" Version="3.0.1" />
<PackageVersion Include="TUnit" Version="0.25.21" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="2.0.2" />
<PackageVersion Include="WireMock.Net" Version="1.6.11" />
</ItemGroup>
<!-- Build -->
<ItemGroup>
Expand Down Expand Up @@ -106,4 +108,4 @@
</PackageVersion>
<PackageVersion Include="xunit.v3" Version="2.0.2" />
</ItemGroup>
</Project>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Telemetry.Abstractions" />
<PackageReference Include="NetEscapades.EnumGenerators" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Gateway for forwarding OTLP telemetry to a collector.
/// </summary>
public interface IOtlpGateway
{
/// <summary>
/// Forwards OTLP telemetry data to the collector.
/// </summary>
/// <param name="signalType">The OTLP signal type (traces, logs, or metrics)</param>
/// <param name="requestBody">The raw OTLP payload stream</param>
/// <param name="contentType">Content-Type of the payload</param>
/// <param name="ctx">Cancellation token</param>
/// <returns>HTTP status code and response content</returns>
Task<(int StatusCode, string? Content)> ForwardOtlp(
OtlpSignalType signalType,
Stream requestBody,
string contentType,
Cancel ctx = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// 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;

/// <summary>
/// Configuration options for the OTLP proxy.
/// The proxy forwards telemetry to a local OTLP collector (typically ADOT Lambda Layer).
/// </summary>
/// <remarks>
/// ADOT Lambda Layer runs a local OpenTelemetry Collector that accepts OTLP/HTTP on:
/// - localhost:4318 (HTTP/JSON and HTTP/protobuf)
/// - localhost:4317 (gRPC)
///
/// 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.
/// </remarks>
public class OtlpProxyOptions
{
/// <summary>
/// OTLP endpoint URL for the local ADOT collector.
/// Defaults to localhost:4318 when running in Lambda with ADOT layer.
/// </summary>
public string Endpoint { get; }

public OtlpProxyOptions(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;
}

// 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";
}
}
Original file line number Diff line number Diff line change
@@ -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.ComponentModel.DataAnnotations;
using NetEscapades.EnumGenerators;

namespace Elastic.Documentation.Api.Core.Telemetry;

/// <summary>
/// OTLP signal types supported by the proxy.
/// The Display names match the OTLP path segments (lowercase).
/// </summary>
[EnumExtensions]
public enum OtlpSignalType
{
/// <summary>
/// Distributed traces - maps to /v1/traces
/// </summary>
[Display(Name = "traces")]
Traces,

/// <summary>
/// Log records - maps to /v1/logs
/// </summary>
[Display(Name = "logs")]
Logs,

/// <summary>
/// Metrics data - maps to /v1/metrics
/// </summary>
[Display(Name = "metrics")]
Metrics
}

/// <summary>
/// Request model for OTLP proxy endpoint.
/// Accepts raw OTLP payload from frontend and forwards to configured OTLP endpoint.
/// </summary>
public class OtlpProxyRequest
{
/// <summary>
/// The OTLP signal type: traces, logs, or metrics
/// </summary>
public required string SignalType { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// 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;

namespace Elastic.Documentation.Api.Core.Telemetry;

/// <summary>
/// Proxies OTLP telemetry from the frontend to the local ADOT Lambda Layer collector.
/// The ADOT layer handles authentication and forwarding to the backend.
/// </summary>
public class OtlpProxyUsecase(IOtlpGateway gateway)
{
private static readonly ActivitySource ActivitySource = new(TelemetryConstants.OtlpProxySourceName);

/// <summary>
/// Proxies OTLP data from the frontend to the local ADOT collector.
/// </summary>
/// <param name="signalType">The OTLP signal type (traces, logs, or metrics)</param>
/// <param name="requestBody">The raw OTLP payload (JSON or protobuf)</param>
/// <param name="contentType">Content-Type header from the original request</param>
/// <param name="ctx">Cancellation token</param>
/// <returns>HTTP status code and response content</returns>
public async Task<(int StatusCode, string? Content)> ProxyOtlp(
OtlpSignalType signalType,
Stream requestBody,
string contentType,
Cancel ctx = default)
{
using var activity = ActivitySource.StartActivity("ProxyOtlp", ActivityKind.Client);

// Forward to gateway
return await gateway.ForwardOtlp(signalType, requestBody, contentType, ctx);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,10 @@ public static class TelemetryConstants
/// Tag/baggage name used to annotate spans with the user's EUID value.
/// </summary>
public const string UserEuidAttributeName = "user.euid";

/// <summary>
/// ActivitySource name for OTLP proxy operations.
/// Used to trace frontend telemetry proxying.
/// </summary>
public const string OtlpProxySourceName = "Elastic.Documentation.Api.OtlpProxy";
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Gateway that forwards OTLP telemetry to the ADOT Lambda Layer collector.
/// </summary>
public class AdotOtlpGateway(
IHttpClientFactory httpClientFactory,
OtlpProxyOptions options,
ILogger<AdotOtlpGateway> logger) : IOtlpGateway
{
public const string HttpClientName = "OtlpProxy";
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);

/// <inheritdoc />
public async Task<(int StatusCode, string? Content)> ForwardOtlp(
OtlpSignalType signalType,
Stream requestBody,
string contentType,
Cancel ctx = default)
{
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)
: 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}");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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(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();

// 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(OtlpSignalType.Metrics, context.Request.Body, contentType, ctx);
return Results.Content(content ?? string.Empty, contentType, statusCode: statusCode);
})
.DisableAntiforgery();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -171,4 +175,27 @@ private static void AddSearchUsecase(IServiceCollection services, AppEnv appEnv)
_ = services.AddScoped<ISearchGateway, ElasticsearchGateway>();
_ = services.AddScoped<SearchUsecase>();
}

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<IConfiguration>();
return new OtlpProxyOptions(config);
});

// Register named HttpClient for OTLP proxy
_ = services.AddHttpClient(AdotOtlpGateway.HttpClientName)
.ConfigureHttpClient(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
});

_ = services.AddScoped<IOtlpGateway, AdotOtlpGateway>();
_ = services.AddScoped<OtlpProxyUsecase>();
logger?.LogInformation("OTLP proxy configured to forward to ADOT Lambda Layer collector");
}
}
Loading
Loading