Send OpenTelemetry traces over OTLP/HTTP
Your service already uses OpenTelemetry and you want its traces to go to a backend you can read. This post gives the exact exporter settings for ZipLogger's OTLP receiver, with working setups for .NET, Node.js and the OpenTelemetry Collector.
What the receiver accepts
| Question | Answer |
|---|---|
| Transport | OTLP over HTTP. gRPC is not accepted. |
| Signals | Traces, logs and metrics |
| Encoding | application/x-protobuf or application/json |
| Compression | gzip, br or deflate (Content-Encoding), or none |
| Auth | X-Api-Key header with an ingest API key |
The endpoints are the standard OTLP paths:
POST https://app.ziplogger.ai/v1/traces
POST https://app.ziplogger.ai/v1/logs
POST https://app.ziplogger.ai/v1/metrics
/ingest/v1/otlp/traces, /ingest/v1/otlp/logs and /ingest/v1/otlp/metrics are aliases that do the same thing. Create the key under Settings → API keys. New keys have the ingest scope by default, which is what these endpoints check.
Check the endpoint with curl first
Before you touch your app, send one span by hand. If this works, any problem you see later is in the exporter config and not in the key or the network.
START=$(date +%s)000000000
END=$(( $(date +%s) + 1 ))000000000
curl -i -X POST https://app.ziplogger.ai/v1/traces \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"curl-test"}}]},"scopeSpans":[{"spans":[{"traceId":"5b8efff798038103d269b633813fc60c","spanId":"eee19b7ec3c1b174","name":"GET /health","kind":2,"startTimeUnixNano":"'"$START"'","endTimeUnixNano":"'"$END"'","status":{"code":1}}]}]}]}'
A 200 with an empty {} body means the span was stored. Open Traces and you will see GET /health from curl-test.
The environment variables
Every OpenTelemetry SDK reads the same variables. For ZipLogger they are:
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT=https://app.ziplogger.ai
export OTEL_EXPORTER_OTLP_HEADERS=X-Api-Key=YOUR_API_KEY
export OTEL_SERVICE_NAME=orders-api
export OTEL_RESOURCE_ATTRIBUTES="service.version=2026.9.1,deployment.environment=production,vcs.ref.head.revision=$(git rev-parse HEAD)"
Three things trip people up:
OTEL_EXPORTER_OTLP_ENDPOINTtakes the origin only. The exporter adds/v1/tracesand/v1/logsitself. If you put the full path here, the request goes to/v1/traces/v1/tracesand nothing arrives.- The signal-specific variables work the other way round.
OTEL_EXPORTER_OTLP_TRACES_ENDPOINTandOTEL_EXPORTER_OTLP_LOGS_ENDPOINTtake the full URL, for examplehttps://app.ziplogger.ai/v1/traces. Use them when you want to send only one signal. - The header is
X-Api-Key=..., notAuthorization=Bearer .... A request withoutX-Api-Keygets a401withMissing X-Api-Key.
Set OTEL_EXPORTER_OTLP_PROTOCOL explicitly. The .NET exporter defaults to gRPC, and ZipLogger does not accept gRPC, so without this line the export fails.
.NET (ASP.NET Core)
Install the OpenTelemetry packages:
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
Then in Program.cs:
using OpenTelemetry.Logs;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
// Traces: incoming requests and outgoing HttpClient calls.
builder.Services.AddOpenTelemetry()
.WithTracing(t => t
.AddAspNetCoreInstrumentation(o => o.RecordException = true)
.AddHttpClientInstrumentation()
.AddOtlpExporter()); // reads the OTEL_* variables above
// Logs: ILogger output over OTLP, with the exception attached when you pass one.
builder.Logging.AddOpenTelemetry(o =>
{
o.IncludeFormattedMessage = true;
o.AddOtlpExporter();
});
var app = builder.Build();
app.MapGet("/", () => "ok");
app.Run();
The service name and version come from OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES, so the code does not repeat them. The ZipLogger environment comes from the ingest key; deployment.environment is stored as an attribute.
RecordException = true adds an exception event to the failing span. ZipLogger copies that event's exception.* attributes onto the span, so the stack trace shows next to the span in the waterfall.
If you would rather configure it in code than in the environment, set the options on the exporter. In code, the endpoint is the full signal URL:
.AddOtlpExporter(o =>
{
o.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.HttpProtobuf;
o.Endpoint = new Uri("https://app.ziplogger.ai/v1/traces");
o.Headers = "X-Api-Key=YOUR_API_KEY";
})
If you only want logs from .NET and do not use OpenTelemetry yet, the native SDK is less setup. See Serilog and .NET logging.
Node.js
Install the SDK, the auto-instrumentations and the protobuf trace exporter:
npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-proto
Create tracing.js:
const { NodeSDK } = require('@opentelemetry/sdk-node')
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node')
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-proto')
const sdk = new NodeSDK({
// Reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS,
// and appends /v1/traces to the endpoint.
traceExporter: new OTLPTraceExporter(),
instrumentations: [getNodeAutoInstrumentations()],
})
sdk.start()
process.on('SIGTERM', () => {
sdk.shutdown().finally(() => process.exit(0))
})
Load it before your app, with the same environment variables as above:
node --require ./tracing.js server.js
The -proto exporter always sends protobuf over HTTP, which is what ZipLogger wants. Do not swap it for @opentelemetry/exporter-trace-otlp-grpc.
This setup sends traces only. Your logs can keep going through whatever logger you use today. Put the active trace id in the log fields as traceId, and each error log gets a View trace link to the request that produced it.
The OpenTelemetry Collector
If your services already send to a Collector, add ZipLogger as an otlphttp exporter:
receivers:
otlp:
protocols:
grpc:
http:
processors:
batch:
exporters:
otlphttp/ziplogger:
traces_endpoint: https://app.ziplogger.ai/v1/traces
logs_endpoint: https://app.ziplogger.ai/v1/logs
metrics_endpoint: https://app.ziplogger.ai/v1/metrics
headers:
X-Api-Key: YOUR_API_KEY
compression: gzip
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/ziplogger]
logs:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/ziplogger]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/ziplogger]
This is also the answer if your apps can only speak gRPC. They send gRPC to the Collector on its otlp receiver, and the Collector forwards over HTTP. On Kubernetes, the ZipLogger Helm chart already runs such an endpoint on every node (gRPC 4317, HTTP 4318) and adds pod and workload names on the way.
For metrics, set your SDKs to delta temporality (OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta). ZipLogger stores one value per interval. It converts cumulative counters itself, but delta stays exact when either side restarts. Histograms arrive as .count, .sum, .avg, .p95, .p99 and .max series, so http.server.request.duration is charted as http.server.request.duration.p95.
What happens to your spans
service.namebecomes the service in the waterfall. Without it, the service shows asunknown.- Span kind maps to
server,client,producer,consumerorinternal. Status maps took,errororunset, and error spans are drawn in red. - Resource and span attributes are merged and kept on the span.
session.idis also stored as its own field. - Only
exceptionspan events are read; other span events are not stored. Span links are kept (up to 32 per span) and appear as dotted edges on the APM service map. - Client spans whose
url.fullpoints at the ZipLogger host are dropped. That is your HTTP instrumentation tracing its own telemetry uploads, which is noise.
Log records map a bit further. service.version becomes the release, vcs.ref.head.revision (or service.commit.sha, or commit.sha) becomes the commit, and the exception.stacktrace attribute becomes the stack trace that git regression detection reads. A log written inside a span keeps its trace id, and that puts a View trace link on the log line. On spans, service.version is what APM uses to assign each request to a release; the commit attribute is kept as a plain attribute.
Responses and limits
| Status | Meaning |
|---|---|
200 | Accepted. If some items were refused, the body has partialSuccess with rejectedSpans or rejectedLogRecords. |
400 | Invalid OTLP payload, with a detail field |
401 | Missing or invalid X-Api-Key |
413 | Body over 20 MB on the wire. Turn on gzip and keep batches small. |
415 | Content type is not application/x-protobuf or application/json |
429 | Plan allowance or daily ceiling reached. Retry-After says when to try again. |
503 | ZipLogger cannot take the request right now: its buffer is full, or its disk journal or a database it needs is unavailable. Comes with Retry-After. Nothing in the request was accepted, or, when the body says "outcome": "unknown", a dependency failed mid-request and the result could not be confirmed. Retry: a resent span overwrites the same stored span. |
A 200 for traces means the accepted spans are written to disk on the server before they are indexed, so a slow or unavailable search store does not lose them. Whether a 429 or 503 is retried is up to the exporter: the Collector and most SDKs retry, but the .NET exporter only does with OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY=in_memory, and only within its export timeout. This is not a promise of zero loss or exactly-once delivery (details).
Spans are metered together with logs, against the same plan allowance. ZipLogger does no sampling on its side, so if auto-instrumentation sends more than you need, sample in the SDK:
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.1
Use the same rate in every service on a request path. Different rates leave holes in your traces.
APM labels span counts as observed spans and does not scale them up. A sampled service shows sampled counts, unless it also sends request metrics.
Next step
The full reference is in the docs under distributed traces. To try it with your own service, sign up, make an API key, and run the curl command above.