Skip to content

Commit f2dc9d0

Browse files
authored
Add OTLP Proxy Endpoint for Frontend Telemetry (#2245)
* Add OTLP proxy endpoint * Fix OtlpProxyOptions * Refactor * Fix CodeQL * Refactor and dispose disposable
1 parent 24f415a commit f2dc9d0

File tree

17 files changed

+718
-67
lines changed

17 files changed

+718
-67
lines changed

Directory.Packages.props

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
<PackageVersion Include="AWSSDK.S3" Version="4.0.7.14" />
2828
<PackageVersion Include="Elastic.OpenTelemetry" Version="1.1.0" />
2929
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" />
30+
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
3031
<PackageVersion Include="Microsoft.Extensions.Telemetry.Abstractions" Version="10.0.0" />
3132
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
3233
<PackageVersion Include="Generator.Equals" Version="3.2.1" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers; buildtransitive" />
@@ -41,6 +42,7 @@
4142
<PackageVersion Include="Microsoft.OpenApi" Version="3.0.1" />
4243
<PackageVersion Include="TUnit" Version="0.25.21" />
4344
<PackageVersion Include="xunit.v3.extensibility.core" Version="2.0.2" />
45+
<PackageVersion Include="WireMock.Net" Version="1.6.11" />
4446
</ItemGroup>
4547
<!-- Build -->
4648
<ItemGroup>
@@ -106,4 +108,4 @@
106108
</PackageVersion>
107109
<PackageVersion Include="xunit.v3" Version="2.0.2" />
108110
</ItemGroup>
109-
</Project>
111+
</Project>

src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
</PropertyGroup>
1010

1111
<ItemGroup>
12+
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
1213
<PackageReference Include="Microsoft.Extensions.Logging" />
1314
<PackageReference Include="Microsoft.Extensions.Telemetry.Abstractions" />
15+
<PackageReference Include="NetEscapades.EnumGenerators" />
1416
</ItemGroup>
1517

1618
</Project>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
namespace Elastic.Documentation.Api.Core.Telemetry;
6+
7+
/// <summary>
8+
/// Gateway for forwarding OTLP telemetry to a collector.
9+
/// </summary>
10+
public interface IOtlpGateway
11+
{
12+
/// <summary>
13+
/// Forwards OTLP telemetry data to the collector.
14+
/// </summary>
15+
/// <param name="signalType">The OTLP signal type (traces, logs, or metrics)</param>
16+
/// <param name="requestBody">The raw OTLP payload stream</param>
17+
/// <param name="contentType">Content-Type of the payload</param>
18+
/// <param name="ctx">Cancellation token</param>
19+
/// <returns>HTTP status code and response content</returns>
20+
Task<(int StatusCode, string? Content)> ForwardOtlp(
21+
OtlpSignalType signalType,
22+
Stream requestBody,
23+
string contentType,
24+
Cancel ctx = default);
25+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using Microsoft.Extensions.Configuration;
6+
7+
namespace Elastic.Documentation.Api.Core.Telemetry;
8+
9+
/// <summary>
10+
/// Configuration options for the OTLP proxy.
11+
/// The proxy forwards telemetry to a local OTLP collector (typically ADOT Lambda Layer).
12+
/// </summary>
13+
/// <remarks>
14+
/// ADOT Lambda Layer runs a local OpenTelemetry Collector that accepts OTLP/HTTP on:
15+
/// - localhost:4318 (HTTP/JSON and HTTP/protobuf)
16+
/// - localhost:4317 (gRPC)
17+
///
18+
/// Configuration priority:
19+
/// 1. OtlpProxy:Endpoint in IConfiguration (for tests/overrides)
20+
/// 2. OTEL_EXPORTER_OTLP_ENDPOINT environment variable
21+
/// 3. Default: http://localhost:4318
22+
///
23+
/// The proxy will return 503 if the collector is not available.
24+
/// </remarks>
25+
public class OtlpProxyOptions
26+
{
27+
/// <summary>
28+
/// OTLP endpoint URL for the local ADOT collector.
29+
/// Defaults to localhost:4318 when running in Lambda with ADOT layer.
30+
/// </summary>
31+
public string Endpoint { get; }
32+
33+
public OtlpProxyOptions(IConfiguration configuration)
34+
{
35+
// Check for explicit configuration override first (for tests or custom deployments)
36+
var configEndpoint = configuration["OtlpProxy:Endpoint"];
37+
if (!string.IsNullOrEmpty(configEndpoint))
38+
{
39+
Endpoint = configEndpoint;
40+
return;
41+
}
42+
43+
// Default to localhost:4318 - this is where ADOT Lambda Layer collector runs
44+
// If ADOT layer is not present, the proxy will fail gracefully and return 503
45+
Endpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT")
46+
?? "http://localhost:4318";
47+
}
48+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.ComponentModel.DataAnnotations;
6+
using NetEscapades.EnumGenerators;
7+
8+
namespace Elastic.Documentation.Api.Core.Telemetry;
9+
10+
/// <summary>
11+
/// OTLP signal types supported by the proxy.
12+
/// The Display names match the OTLP path segments (lowercase).
13+
/// </summary>
14+
[EnumExtensions]
15+
public enum OtlpSignalType
16+
{
17+
/// <summary>
18+
/// Distributed traces - maps to /v1/traces
19+
/// </summary>
20+
[Display(Name = "traces")]
21+
Traces,
22+
23+
/// <summary>
24+
/// Log records - maps to /v1/logs
25+
/// </summary>
26+
[Display(Name = "logs")]
27+
Logs,
28+
29+
/// <summary>
30+
/// Metrics data - maps to /v1/metrics
31+
/// </summary>
32+
[Display(Name = "metrics")]
33+
Metrics
34+
}
35+
36+
/// <summary>
37+
/// Request model for OTLP proxy endpoint.
38+
/// Accepts raw OTLP payload from frontend and forwards to configured OTLP endpoint.
39+
/// </summary>
40+
public class OtlpProxyRequest
41+
{
42+
/// <summary>
43+
/// The OTLP signal type: traces, logs, or metrics
44+
/// </summary>
45+
public required string SignalType { get; init; }
46+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Diagnostics;
6+
7+
namespace Elastic.Documentation.Api.Core.Telemetry;
8+
9+
/// <summary>
10+
/// Proxies OTLP telemetry from the frontend to the local ADOT Lambda Layer collector.
11+
/// The ADOT layer handles authentication and forwarding to the backend.
12+
/// </summary>
13+
public class OtlpProxyUsecase(IOtlpGateway gateway)
14+
{
15+
private static readonly ActivitySource ActivitySource = new(TelemetryConstants.OtlpProxySourceName);
16+
17+
/// <summary>
18+
/// Proxies OTLP data from the frontend to the local ADOT collector.
19+
/// </summary>
20+
/// <param name="signalType">The OTLP signal type (traces, logs, or metrics)</param>
21+
/// <param name="requestBody">The raw OTLP payload (JSON or protobuf)</param>
22+
/// <param name="contentType">Content-Type header from the original request</param>
23+
/// <param name="ctx">Cancellation token</param>
24+
/// <returns>HTTP status code and response content</returns>
25+
public async Task<(int StatusCode, string? Content)> ProxyOtlp(
26+
OtlpSignalType signalType,
27+
Stream requestBody,
28+
string contentType,
29+
Cancel ctx = default)
30+
{
31+
using var activity = ActivitySource.StartActivity("ProxyOtlp", ActivityKind.Client);
32+
33+
// Forward to gateway
34+
return await gateway.ForwardOtlp(signalType, requestBody, contentType, ctx);
35+
}
36+
}

src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,10 @@ public static class TelemetryConstants
2525
/// Tag/baggage name used to annotate spans with the user's EUID value.
2626
/// </summary>
2727
public const string UserEuidAttributeName = "user.euid";
28+
29+
/// <summary>
30+
/// ActivitySource name for OTLP proxy operations.
31+
/// Used to trace frontend telemetry proxying.
32+
/// </summary>
33+
public const string OtlpProxySourceName = "Elastic.Documentation.Api.OtlpProxy";
2834
}

src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,15 @@ public async Task<Stream> AskAi(AskAiRequest askAiRequest, Cancel ctx = default)
3737
var kibanaUrl = await parameterProvider.GetParam("docs-kibana-url", false, ctx);
3838
var kibanaApiKey = await parameterProvider.GetParam("docs-kibana-apikey", true, ctx);
3939

40-
var request = new HttpRequestMessage(HttpMethod.Post,
40+
using var request = new HttpRequestMessage(HttpMethod.Post,
4141
$"{kibanaUrl}/api/agent_builder/converse/async")
4242
{
4343
Content = new StringContent(requestBody, Encoding.UTF8, "application/json")
4444
};
4545
request.Headers.Add("kbn-xsrf", "true");
4646
request.Headers.Authorization = new AuthenticationHeaderValue("ApiKey", kibanaApiKey);
4747

48-
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx);
48+
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx);
4949

5050
// Ensure the response is successful before streaming
5151
if (!response.IsSuccessStatusCode)

src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public async Task<Stream> AskAi(AskAiRequest askAiRequest, Cancel ctx = default)
2525
{
2626
var llmGatewayRequest = LlmGatewayRequest.CreateFromRequest(askAiRequest);
2727
var requestBody = JsonSerializer.Serialize(llmGatewayRequest, LlmGatewayContext.Default.LlmGatewayRequest);
28-
var request = new HttpRequestMessage(HttpMethod.Post, options.FunctionUrl)
28+
using var request = new HttpRequestMessage(HttpMethod.Post, options.FunctionUrl)
2929
{
3030
Content = new StringContent(requestBody, Encoding.UTF8, "application/json")
3131
};
@@ -37,7 +37,7 @@ public async Task<Stream> AskAi(AskAiRequest askAiRequest, Cancel ctx = default)
3737

3838
// Use HttpCompletionOption.ResponseHeadersRead to get headers immediately
3939
// This allows us to start streaming as soon as headers are received
40-
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx);
40+
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx);
4141

4242
// Ensure the response is successful before streaming
4343
if (!response.IsSuccessStatusCode)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using Elastic.Documentation.Api.Core.Telemetry;
6+
using Microsoft.Extensions.Logging;
7+
8+
namespace Elastic.Documentation.Api.Infrastructure.Adapters.Telemetry;
9+
10+
/// <summary>
11+
/// Gateway that forwards OTLP telemetry to the ADOT Lambda Layer collector.
12+
/// </summary>
13+
public class AdotOtlpGateway(
14+
IHttpClientFactory httpClientFactory,
15+
OtlpProxyOptions options,
16+
ILogger<AdotOtlpGateway> logger) : IOtlpGateway
17+
{
18+
public const string HttpClientName = "OtlpProxy";
19+
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
20+
21+
/// <inheritdoc />
22+
public async Task<(int StatusCode, string? Content)> ForwardOtlp(
23+
OtlpSignalType signalType,
24+
Stream requestBody,
25+
string contentType,
26+
Cancel ctx = default)
27+
{
28+
try
29+
{
30+
// Build the target URL: http://localhost:4318/v1/{signalType}
31+
// Use ToStringFast(true) from generated enum extensions (returns Display name: "traces", "logs", "metrics")
32+
var targetUrl = $"{options.Endpoint.TrimEnd('/')}/v1/{signalType.ToStringFast(true)}";
33+
34+
logger.LogDebug("Forwarding OTLP {SignalType} to ADOT collector at {TargetUrl}", signalType, targetUrl);
35+
36+
using var request = new HttpRequestMessage(HttpMethod.Post, targetUrl);
37+
38+
// Forward the content with the original content type
39+
request.Content = new StreamContent(requestBody);
40+
_ = request.Content.Headers.TryAddWithoutValidation("Content-Type", contentType);
41+
42+
// No need to add authentication headers - ADOT layer handles auth to backend
43+
// Just forward the telemetry to the local collector
44+
45+
// Forward to ADOT collector
46+
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, ctx);
47+
var responseContent = response.Content.Headers.ContentLength > 0
48+
? await response.Content.ReadAsStringAsync(ctx)
49+
: string.Empty;
50+
51+
if (!response.IsSuccessStatusCode)
52+
{
53+
logger.LogError("OTLP forward to ADOT failed with status {StatusCode}: {Content}",
54+
response.StatusCode, responseContent);
55+
}
56+
else
57+
{
58+
logger.LogDebug("Successfully forwarded OTLP {SignalType} to ADOT collector", signalType);
59+
}
60+
61+
return ((int)response.StatusCode, responseContent);
62+
}
63+
catch (HttpRequestException ex) when (ex.Message.Contains("Connection refused") || ex.InnerException?.Message?.Contains("Connection refused") == true)
64+
{
65+
logger.LogError(ex, "Failed to connect to ADOT collector at {Endpoint}. Is ADOT Lambda Layer enabled?", options.Endpoint);
66+
return (503, "ADOT collector not available. Ensure AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-instrument is set");
67+
}
68+
catch (Exception ex)
69+
{
70+
logger.LogError(ex, "Error forwarding OTLP {SignalType}", signalType);
71+
return (500, $"Error forwarding OTLP: {ex.Message}");
72+
}
73+
}
74+
}

0 commit comments

Comments
 (0)