Skip to content

Telemetry: Nwire → OpenTelemetry → GreptimeDB

Stream every action, event, transition, and query out of your Nwire app into GreptimeDB — a time-series + observability database that speaks OTLP natively, exposes a PostgreSQL wire protocol for SQL, and is fast enough to render Studio Live history from cold storage.

This recipe ships two routing paths. Pick one per app.

PathHop countWhen to use
A1 hopSingle Nwire service, no sampling needed. Simplest. Direct OTLP/HTTP.
B2 hopsSampling, multi-sink fan-out, or buffering. Vector in the middle.

0. Boot the stack (local dev)

bash
docker compose -f docker-compose.yml -f docker-compose.telemetry.yml up -d

This adds two services to your local stack:

ServicePortsPurpose
greptimedb4000 HTTP, 4001 gRPC, 4003 PGStorage + SQL
vector4317 gRPC, 4318 HTTP, 8686 APIRouting layer (Path B only)

Health-check:

bash
curl http://localhost:4000/health     # greptime
curl http://localhost:8686/health     # vector

Path A — direct OTLP/HTTP → Greptime

The minimum-moving-parts shape. Greptime accepts OTLP natively at POST /v1/otlp/v1/traces — no Vector required.

App wiring

bash
pnpm add @nwire/telemetry-otel \
         @opentelemetry/api \
         @opentelemetry/sdk-trace-node \
         @opentelemetry/exporter-trace-otlp-proto \
         @opentelemetry/resources \
         @opentelemetry/semantic-conventions

Use exporter-trace-otlp-proto, not -http. Greptime's OTLP endpoint only accepts application/x-protobuf; the -http exporter sends JSON and will be rejected with a clear 400 error.

ts
// apps/main/wires/telemetry.ts
import { NodeTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";

export function bootTracing(serviceName: string): void {
  const provider = new NodeTracerProvider({
    resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: serviceName }),
    spanProcessors: [
      new BatchSpanProcessor(
        new OTLPTraceExporter({
          url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
            ?? "http://localhost:4000/v1/otlp/v1/traces",
          // REQUIRED — Greptime routes OTLP through its `greptime_trace_v1`
          // pipeline; without this header the request 400s with
          // "Pipeline is required for this API.".
          headers: { "x-greptime-pipeline-name": "greptime_trace_v1" },
        }),
      ),
    ],
  });
  provider.register();
}
ts
// apps/main/main.ts
import { endpoint } from "@nwire/endpoint";
import { attachOtelExporter } from "@nwire/telemetry-otel";
import { trace } from "@opentelemetry/api";
import { bootTracing } from "./wires/telemetry";
import { app } from "./app";
import { api } from "./api";

bootTracing("station-management");
await app.start();
attachOtelExporter(app.runtime, { tracer: trace.getTracer("nwire") });
api.inspect(app);

await endpoint("api", { port: 3003 }).serve(api).run();

Query

bash
# Via psql (Greptime speaks the Postgres wire protocol):
psql "postgresql://greptime_user@localhost:4003/public"

> SELECT
    trace_id,
    span_name,
    duration_nano / 1e6 AS ms,
    span_attributes ->> 'persona' AS persona
  FROM traces
  WHERE service_name = 'station-management'
  ORDER BY timestamp DESC
  LIMIT 20;

Or via Greptime's HTTP API:

bash
curl -X POST http://localhost:4000/v1/sql \
  -H 'content-type: application/x-www-form-urlencoded' \
  --data 'sql=SELECT span_name, count(*) FROM traces GROUP BY span_name'

Path B — Vector in the middle (logs only)

Use when you need any of:

  • Log routing — newline-delimited JSON logs from @nwire/logger-pino
  • Fan-out — fork logs to multiple sinks (Greptime + S3 archive)
  • Sampling / enrichment — tag every record with deploy_env, region, tenant

Trace caveat: Vector's current http sink can't re-encode parsed OTLP events back into protobuf, and Greptime's OTLP endpoint only accepts application/x-protobuf (JSON returns a clear error). For traces, use Path A direct, or put an OTel Collector (otel/opentelemetry-collector:latest) in front of Greptime instead of Vector.

Log shipping

ts
// app side: pino-pretty or just newline-JSON to stdout, then any log
// shipper (filebeat / vector tail / k8s pod log) → Vector OTLP source.

The Vector → Greptime log hop is defined in .docker/vector.toml:

toml
[sinks.greptime_logs]
type = "http"
inputs = ["tag_env"]
uri = "http://greptimedb:4000/v1/events/logs?db=public&table=nwire_logs"
encoding.codec = "json"
framing.method = "newline_delimited"

Add a Honeycomb sink alongside Greptime by editing .docker/vector.toml:

toml
[sinks.honeycomb_traces]
type = "honeycomb"
inputs = ["tag_env"]
api_key = "${HONEYCOMB_API_KEY}"
dataset = "nwire-prod"

Reload Vector to pick it up:

bash
docker compose restart vector

Production checklist

DecisionRecommendation
Where does Greptime run?Cloud-managed (greptime.cloud) or your K8s cluster — never on the app box.
Sampling rate for query.executed?Default query.executed to ~10% in prod; keep 100% in staging.
What carries tenant?MessageEnvelope.tenant flows through every record. Index on it in Greptime.
How long to retain?Greptime has TTL policies per table — typically 7 days hot, 90 days cold.
Failure mode if Greptime is down?With Vector: app keeps running, Vector buffers to disk. Without: spans drop silently.

What you get free

Because @nwire/telemetry-otel walks the canonical Telemetry stream (13 kinds, Phase 34) and Greptime renders the standard OTLP schema:

  • Causation-id-keyed span parents — every reaction shows its triggering event
  • persona + journeyStep + slo from defineAction({...}) land as span attributes
  • Tenant + correlation IDs carried automatically through the envelope chain
  • actor.transitioned records become span events on the actor's span — you see state-machine transitions inside the same trace as the action that triggered them

Studio's Trace page reads the same /_nwire/telemetry/* SSE stream in-process; Greptime stores the same records for cross-deploy history. One stream, two consumers.

MIT licensed.