Blog Sending logs and traces

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

QuestionAnswer
TransportOTLP over HTTP. gRPC is not accepted.
SignalsTraces, logs and metrics
Encodingapplication/x-protobuf or application/json
Compressiongzip, br or deflate (Content-Encoding), or none
AuthX-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_ENDPOINT takes the origin only. The exporter adds /v1/traces and /v1/logs itself. If you put the full path here, the request goes to /v1/traces/v1/traces and nothing arrives.
  • The signal-specific variables work the other way round. OTEL_EXPORTER_OTLP_TRACES_ENDPOINT and OTEL_EXPORTER_OTLP_LOGS_ENDPOINT take the full URL, for example https://app.ziplogger.ai/v1/traces. Use them when you want to send only one signal.
  • The header is X-Api-Key=..., not Authorization=Bearer .... A request without X-Api-Key gets a 401 with Missing 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.name becomes the service in the waterfall. Without it, the service shows as unknown.
  • Span kind maps to server, client, producer, consumer or internal. Status maps to ok, error or unset, and error spans are drawn in red.
  • Resource and span attributes are merged and kept on the span. session.id is also stored as its own field.
  • Only exception span 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.full points 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

StatusMeaning
200Accepted. If some items were refused, the body has partialSuccess with rejectedSpans or rejectedLogRecords.
400Invalid OTLP payload, with a detail field
401Missing or invalid X-Api-Key
413Body over 20 MB on the wire. Turn on gzip and keep batches small.
415Content type is not application/x-protobuf or application/json
429Plan allowance or daily ceiling reached. Retry-After says when to try again.
503ZipLogger 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.

Follow one error across logs, traces and events

Correlate logs, traces and product events by trace id and session id on ZipLogger's Investigate page, and what your apps must send for the join to work.

Trace a click from the browser to the backend

Send a W3C traceparent header from the browser so your backend's OpenTelemetry spans join the same trace. CORS setup and real output included.

Kubernetes monitoring with one Helm chart

Install one read-only Helm chart per cluster and see nodes, workloads, pods and events, plus crash loops, image pull errors and missing Secrets as incidents.

Try it on your own logs

There is a free plan and no sales call. Install an SDK, send a few log lines, and search them.