Skip to content

Observability in practice: SigNoz, logs, traces

Every service in the fleet — Go and Python — speaks OpenTelemetry and exports traces, metrics and logs to a self-hosted SigNoz over OTLP, stamping a trace_id onto every log line. This page is the operator’s side of that: how to query SigNoz from your shell, how to tell whether a service is even exporting, how to turn the volume up with LOG_LEVEL, and where the raw container logs live when the pipeline itself is suspect.

For the why (the OTel bootstrap, browser RUM, the queue-hop traceparent carrier, record_error, force-sample-on-error) see the Observability & tracing model architecture page. This page assumes it.

All backend services and the web app export OTLP to one SigNoz instance (traces + metrics

  • logs in ClickHouse) on a dedicated Azure VM, fronted by Caddy for TLS and a static bearer-token gate — SigNoz’s receiver has no built-in auth. The whole setup lives in soundverse-proto/infra/signoz/ (Caddyfile, docker-compose.override.yaml, OBSERVABILITY.md). SigNoz is your primary pane of glass — search by trace id, by session.id, or by service.name + service.version.

The UI is at https://signoz.soundverse.ai, but you can hit the API directly with a read-only Viewer key for scripted debugging. The deployment is v0.129.0 (EE) and exposes the v5 query API; the key travels in the SIGNOZ-API-KEY request header.

  1. Load the creds. The endpoint and key live outside any git repo in ~/.config/soundverse/signoz.env (mode 600). Source it — it exports SIGNOZ_URL and SIGNOZ_API_KEY into your shell:

    Terminal window
    source ~/.config/soundverse/signoz.env
  2. Confirm reachability / version. A plain GET works for the version endpoint:

    Terminal window
    curl -s "$SIGNOZ_URL/api/v1/version" \
    -H "SIGNOZ-API-KEY: $SIGNOZ_API_KEY"
  3. Pull RED metrics for the service list. This one is a POST, and the timestamps have a trap:

    Terminal window
    curl -s "$SIGNOZ_URL/api/v1/services" \
    -H "SIGNOZ-API-KEY: $SIGNOZ_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"start":"<ns>","end":"<ns>","tags":[]}'

The heartbeat canary: is this service even exporting?

Section titled “The heartbeat canary: is this service even exporting?”

The failure mode that hides in plain sight is a service whose logs simply aren’t in SigNoz — usually a stale ACA revision that predates the OTLP env, not a code bug (the export wiring is uniform across the fleet). The tell is a liveness canary: every healthy service emits an INFO log line telemetry.heartbeat service=<name> on a fixed interval. Group SigNoz Logs by service.name; the absence of a heartbeat is the signal that a service isn’t exporting.

The knobs that govern it are plain env-var names (never commit the values):

Env var Purpose
OTEL_EXPORTER_OTLP_ENDPOINT The master switch. Unset → no exporters installed at all; the service falls back to trace-correlated JSON on stdout with no connection errors. That is why local runs and tests need no collector.
OTEL_EXPORTER_OTLP_HEADERS The ingest bearer header the collector gates on (resolved from an org secret). If the endpoint is set but this is empty, the bootstrap logs a WARN.
OTEL_HEARTBEAT_SECONDS Heartbeat cadence. Default 60; 0 disables the canary.
LOG_LEVEL debug | info | warn | error, default info. Gates both stdout and the OTLP log bridge (see below).
OTEL_SERVICE_VERSION / GITHUB_SHA Sets service.version on every span so you can group by release; an explicit OTEL_SERVICE_VERSION wins over the build-arg SHA.
ENVIRONMENT Becomes the deployment.environment resource attribute (local / staging / prod).

The canary is wired identically in both languages — Python in soundverse-py/src/soundverse/telemetry.py (which covers all three tool workers and the agent via the fleet), Go in core-mcp/internal/telemetry/telemetry.go and the matching core-database module.

LOG_LEVEL gates both the stdout handler and the OTLP→SigNoz bridge in lockstep, so setting LOG_LEVEL=debug on a staging revision gives you a full play-by-play in SigNoz, not just on the container’s stdout.

parseLevel(os.Getenv("LOG_LEVEL")) feeds a leveled wrapper around the otelslog handler — otelslog has no level option of its own, so this wrapper is what keeps SigNoz log volume tied to the level. Default info.

Reading a single tool call: verbose core-mcp logging

Section titled “Reading a single tool call: verbose core-mcp logging”

core-mcp is the second billing pipeline, and its tool-call handler used to be near-silent. It now binds a request-scoped logger the moment a call arrives — slog.With("call_id", …, "tool", …, "operation", …, "tool_id", …, "user_id", …, "workspace_id", …, "project_id", …) in core-mcp/internal/toolcall/handler.go — and logs every milestone against it: tool call received, tool call rate-limited, tool unpriced, task queued, completion, and generation failures. Filter SigNoz Logs by a single call_id and you reconstruct the whole tool call end to end.

That covers the classic “chat is stuck on planning” triage: search the call_id (or the displayed trace_id) and the reason is usually one log line away — tool unpriced (no pricing row → NotFound; see Add pricing to a tool), tool call rate-limited, or an upstream RPC error.

SigNoz filtering is only as good as the attributes on the record. The high-value ones, stamped fleet-wide:

Attribute Where it’s set Use it to
trace_id every log line, both languages pivot log ↔ trace; it’s the id shown in the chat trace badge
session.id every browser span (per-tab) replay one user’s whole journey in order
task.id gateway, worker task.run, BFF stream tie the generation trace to the live-read trace
user.id gateway generation RPCs, worker task.run “everything user X did”
service.name + service.version every span/log scope to one service and one release
call_id core-mcp tool-call logs reconstruct a single tool call

The chat UI surfaces the trace_id as a small trace badge next to each generation (the agentone.run root span is force-sampled so a displayed id is never a dropped trace). A user quoting “my song failed” can hand you that id; paste it into SigNoz for the entire run — browser → BFF → gateway → core-database → worker.

When telemetry export itself is suspect, go under it to the container’s stdout (the same trace-correlated JSON, just not via ClickHouse). The resource group is rg-core:

Follow one Container App
az containerapp logs show -n core-gateway-consumer-staging -g rg-core --follow
Multi-replica: list, then stream each
az containerapp replica list -n core-storage-staging -g rg-core \
--query "[].name" -o tsv

To read the effective env/secret refs on a running revision without leaking values, see Inspect a running service’s env & secrets.

Metrics that exist (and the alerting that doesn’t)

Section titled “Metrics that exist (and the alerting that doesn’t)”

The money path and the queue are instrumented, so a leaked hold or a backing-up pipeline is visible in SigNoz rather than surfacing as a billing complaint:

Metric Emitted by Answers
settlement_failure_total, refund_failure_total core-gateway-consumer/app/core/metrics.py (low-cardinality) is settlement leaking?
generation_latency_seconds, token_usage_total the worker fleet how slow / how much?
generation_queue_depth core-database DB-gauge, by tool_id is the pipeline backing up?
unsettled_task_age_seconds core-database DB-gauge oldest terminal task the reconciler hasn’t settled

Planned Alerting and SLOs are not wired yet. These counters emit, but nothing pages on refund_failure_total > 0 and there is no time-to-settle target. See Known limitations for the planned golden-metric set. The billing reconciler is the current backstop.

In code, not deployed A tail-sampling collector (otelcol-tail.yaml, wired into the SigNoz docker-compose.override.yaml + Caddyfile) is designed to keep any trace that contains an error, is force-sampled, or is slow — closing the “downstream error on a head-sampled-out trace” gap. It’s configured in-repo; confirm it’s actually live on the SigNoz VM before relying on it.