Spec 2b is live (2026-05-27)
Single OTel collector on the polymarket-infra VM carries four independent signal sources to SigNoz Cloud EU2. Apps emit logs+traces over OTLP gRPC with zero per-app config — the apps Ansible role injects standard
OTEL_*env vars andhost.docker.internal:host-gatewayautomatically.
Architecture
┌──────────────────────────────┐
│ polymarket-infra VM │
│ europe-west3-a, e2-small │
│ │
apps in containers ──→ │ host.docker.internal:4317 │ (OTLP gRPC, in-cluster)
(tracing+OTel SDK) │ host.docker.internal:4318 │ (OTLP HTTP, in-cluster)
│ │ │
│ ▼ │
tailscaled ←─────────→│ 127.0.0.1:5252/debug/metrics│ (Prometheus scrape)
(Tailscale daemon) │ │ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ otelcol-contrib │ │
│ │ v0.152.1, systemd │ │
│ │ /etc/otelcol-contrib/ │ │
│ │ config.yaml │ │
│ └────────────────────────┘ │
│ │ │
│ │ + host_metrics │
│ │ (built-in) │
│ │ │
│ ▼ │
│ resourcedetection [system, │
│ gcp] → batch processors │
│ │ │
└───────────┼──────────────────┘
│
▼ OTLP gRPC + signoz-ingestion-key header
ingest.eu2.signoz.cloud:443 (TLS, port 443)
│
▼
┌─────────────────┐
│ SigNoz Cloud │
│ EU2 (Frankfurt)│
└─────────────────┘
Three signals + their sources
| Signal | Source | Receiver in collector | Pipeline |
|---|---|---|---|
| Metrics — host | OTel host_metrics (CPU, memory, disk, network, paging, load, filesystem; *.utilization series explicitly opted in since 2026-06-18 — see [[gcp-terraform-ansible-gotchas#36. OTel host_metrics *.utilization metrics are NOT emitted by default|gotcha #36]]) | host_metrics (built-in, 60s collection_interval) | metrics |
| Metrics — Tailscale | tailscaled --debug=127.0.0.1:5252 (Prometheus format at /debug/metrics) | prometheus scraping 127.0.0.1:5252 every 60s | metrics |
| Metrics — NATS (since 2026-06-18) | prometheus-nats-exporter:0.17.3 sidecar in the [[signoz-dashboard-housekeeping-2026-06-18#A1. NATS sidecar — prometheus-nats-exporter|nats compose project]] translates NATS’s JSON :8222 endpoint into Prometheus format, exposes on 127.0.0.1:7777 (flags: -varz -connz -subz -routez -gatewayz -leafz -jsz=all). NATS :8222 is JSON, not Prom — see [[gcp-terraform-ansible-gotchas#37. NATS :8222 is JSON, not Prometheus — needs prometheus-nats-exporter|gotcha #37]] | prometheus scraping 127.0.0.1:7777 every 60s | metrics |
| Traces | App SDK (tracing-opentelemetry for Rust) over OTLP/gRPC | otlp.grpc (0.0.0.0:4317) | traces |
| Logs | App SDK (opentelemetry-appender-tracing bridge for Rust) over OTLP/gRPC | otlp.grpc (0.0.0.0:4317) | logs |
All five end up at the same otlp_grpc exporter pointing at ingest.eu2.signoz.cloud:443 with signoz-ingestion-key header from secrets/signoz_ingestion_key.
Files to read
- Collector config:
ansible/roles/monitoring/templates/config.yaml.j2 - Collector role:
ansible/roles/monitoring/tasks/main.yml(installs otelcol-contrib, configures tailscaled--debugflag, writes config) - Compose template (injects
OTEL_*env vars for every container):ansible/roles/apps/templates/docker-compose.yml.j2 - Apps role defaults:
ansible/roles/apps/defaults/main.yml(apps_environment: prod) - Rust app integration example:
crates/polymarket-fetch/src/observability.rsinwowjeeez/polymarket-fetch
Auto-injection: every container gets these env vars
The apps role’s compose template renders this block on every entry by default (opt out per entry with telemetry: false):
environment:
OTEL_SERVICE_NAME: "{{ item.name }}"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://host.docker.internal:4317"
OTEL_EXPORTER_OTLP_PROTOCOL: "grpc"
OTEL_RESOURCE_ATTRIBUTES: "deployment.environment={{ apps_environment }},service.namespace=apps"
extra_hosts:
- "host.docker.internal:host-gateway"host.docker.internal:host-gateway is Docker’s built-in alias for the host’s bridge gateway IP (typically 172.17.0.1 on Linux). Container reaches the host’s :4317 via that gateway — no port binding from outside the VM required.
Onboarding a new app — Rust (tracing + tracing-opentelemetry)
The auto-injected env vars are honored by any standard OTel SDK. For Rust with tracing + tracing-opentelemetry + opentelemetry-appender-tracing, drop this single file in the crate and call observability::init(...) from main. All boilerplate, zero per-app config.
Cargo.toml
Workspace deps (verified compatible 2026-05-27):
opentelemetry = "0.28"
opentelemetry_sdk = { version = "0.28", features = ["rt-tokio"] }
opentelemetry-otlp = { version = "0.28", default-features = false, features = ["grpc-tonic", "trace", "logs"] }
opentelemetry-appender-tracing = "0.28"
tracing-opentelemetry = "0.29"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "fmt"] }Version pin matters
The OTel-Rust ecosystem renamed APIs in 0.28 (
SdkLoggerProvider/SdkTracerProvider/Resource::builder()). Stick with the 0.28-line. If you bump, expect breakage. (gcp-terraform-ansible-gotchas item to add when it bites.)
src/observability.rs
use std::time::Duration;
use anyhow::{Context, Result};
use opentelemetry::{global, trace::TracerProvider as _};
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::{
Resource,
logs::SdkLoggerProvider,
propagation::TraceContextPropagator,
trace::SdkTracerProvider,
};
use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt};
const TRACER_NAME: &str = "my-app";
const DEFAULT_SERVICE_NAME: &str = "my-app";
const OTLP_EXPORT_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Default)]
pub struct Guard {
tracer_provider: Option<SdkTracerProvider>,
logger_provider: Option<SdkLoggerProvider>,
}
impl Drop for Guard {
fn drop(&mut self) {
if let Some(tp) = self.tracer_provider.take() {
let _ = tp.shutdown();
}
if let Some(lp) = self.logger_provider.take() {
let _ = lp.shutdown();
}
}
}
pub fn init(default_filter: &str, json_logs: bool) -> Result<Guard> {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(default_filter));
let registry = tracing_subscriber::registry().with(filter);
let fmt_layer = if json_logs {
tracing_subscriber::fmt::layer().with_writer(std::io::stderr).json().boxed()
} else {
tracing_subscriber::fmt::layer().with_writer(std::io::stderr).boxed()
};
// OTEL_EXPORTER_OTLP_ENDPOINT is set by the apps Ansible role on the VM.
// When absent (local cargo run), we fall back to stderr-only — same UX as before.
let Some(endpoint) = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok() else {
registry.with(fmt_layer).init();
return Ok(Guard::default());
};
global::set_text_map_propagator(TraceContextPropagator::new());
let service_name = std::env::var("OTEL_SERVICE_NAME")
.unwrap_or_else(|_| DEFAULT_SERVICE_NAME.to_string());
let resource = Resource::builder().with_service_name(service_name).build();
let span_exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic().with_endpoint(&endpoint).with_timeout(OTLP_EXPORT_TIMEOUT)
.build().context("build OTLP span exporter")?;
let tracer_provider = SdkTracerProvider::builder()
.with_batch_exporter(span_exporter).with_resource(resource.clone()).build();
global::set_tracer_provider(tracer_provider.clone());
let tracer = tracer_provider.tracer(TRACER_NAME);
let span_layer = tracing_opentelemetry::layer().with_tracer(tracer);
let log_exporter = opentelemetry_otlp::LogExporter::builder()
.with_tonic().with_endpoint(&endpoint).with_timeout(OTLP_EXPORT_TIMEOUT)
.build().context("build OTLP log exporter")?;
let logger_provider = SdkLoggerProvider::builder()
.with_batch_exporter(log_exporter).with_resource(resource).build();
let log_bridge = OpenTelemetryTracingBridge::new(&logger_provider);
registry.with(fmt_layer).with(span_layer).with(log_bridge).init();
Ok(Guard { tracer_provider: Some(tracer_provider), logger_provider: Some(logger_provider) })
}Wire-up in main
#[tokio::main]
async fn main() -> Result<()> {
let _guard = observability::init("info,my_app=info", false)?;
// ... rest of main; spans + tracing macros now ship to SigNoz automatically
Ok(())
}Hold the Guard until end-of-main so the batch exporters flush on shutdown.
Onboarding non-Rust apps
The OTel SDK in any language reads the standard OTEL_* env vars. Concretely for the most common stacks:
- Go:
go.opentelemetry.io/otel/sdk+otlptracegrpcandotlploggrpcexporters withWithEndpoint(os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))(or use the SDK auto-configure path which reads env directly). - Python:
opentelemetry-sdk+opentelemetry-exporter-otlp-proto-grpc+opentelemetry-instrumentationfor auto-instrumentation. Env vars are auto-honored. - Node:
@opentelemetry/sdk-nodewith@opentelemetry/auto-instrumentations-node. Same env vars.
Pattern is identical across languages: the apps role sets the env vars, the SDK reads them, signals flow. No per-app collector endpoint config in code.
Verifying signals are flowing
From the VM (collector-side)
The collector exposes its own metrics on :8888/metrics. Look for paired counters — incoming accepted should match outgoing sent, with zero send_failed:
ssh ops@polymarket-infra curl -s http://127.0.0.1:8888/metrics | grep -E \
'^otelcol_(receiver_accepted|exporter_sent|exporter_send_failed)_(log_records|metric_points|spans)\b'Healthy steady-state example (post Spec 2b deploy, ~30 min):
otelcol_receiver_accepted_log_records{receiver="otlp",transport="grpc"} 41
otelcol_exporter_sent_log_records{exporter="otlp_grpc",server_address="ingest.eu2.signoz.cloud",server_port="443"} 41
otelcol_receiver_accepted_metric_points{receiver="host_metrics",transport=""} 33765
otelcol_exporter_sent_metric_points{exporter="otlp_grpc",server_address="ingest.eu2.signoz.cloud",server_port="443"} 33765
otelcol_receiver_accepted_spans{receiver="otlp",transport="grpc"} 143
otelcol_exporter_sent_spans{exporter="otlp_grpc",server_address="ingest.eu2.signoz.cloud",server_port="443"} 143
accepted == sent, zero send_failed lines → end-to-end clean.
See also gotcha 9 for the canonical signal pattern.
From SigNoz Cloud (consumer-side)
- Services view:
https://eu2.signoz.cloud/services— lists every service emitting traces with call rate, p99, error rate. Right now:polymarket-fetch-bot,polymarket-fetch-wallet-monitor,pm-arb-validate,polymarket-fetch-scheduled-run(when its timer fires). - Logs view:
https://eu2.signoz.cloud/logs— filterservice.name=polymarket-fetch-bot(or anyOTEL_SERVICE_NAMEvalue). - Metrics view:
https://eu2.signoz.cloud/metrics-explorer— querysystem_*(host) andcontrolclient_*/derp_*/dns_*/magicsock_*/goroutines(tailscaled). - Dashboards:
https://eu2.signoz.cloud/dashboard— three production boards (also see signoz-dashboard-housekeeping-2026-06-18):polymarket-infra — Host & Tailscale—019e6a1c-5be5-7383-83ff-23f8562038f4polymarket-infra — App services (APM)—019e6a21-9eee-7e14-9603-1e00ebd59194polymarket-infra — NATS + JetStream—019eda9d-139f-7526-b22d-c4d8a4a3cdf2(added 2026-06-18; populated onceprometheus-nats-exporterships viamake configure)
What’s NOT shipped to SigNoz
- Per-container resource metrics (docker stats). The collector doesn’t run a
docker_statsreceiver. If we want this, add the docker_stats receiver — requires giving the collector access to/var/run/docker.sock. Not done because host_metrics already covers aggregate resource pressure. - Supabase / Polymarket API outbound metrics. The apps’ own traces capture HTTP/Postgres outbound as spans, but if we ever want per-host RED metrics for outbound APIs, we’d add a
httpcheckorsqlserverreceiver-style receiver (no Postgres exporter currently configured — see Supabase metrics path below). - Cardholder/PII. Per OTel semantic conventions,
OTEL_RESOURCE_ATTRIBUTESisdeployment.environment+service.namespaceonly. No user IDs, no auth tokens — those stay in app-side trace attributes and are filtered by the SDK before export.
Supabase metrics path
Supabase Free tier: no native external metric export
Supabase doesn’t expose Prometheus or OTLP for the database engine on free/pro tiers. The dashboard UI at
https://supabase.com/dashboard/project/mkofmdtdldxgmmolxxhc/database/reportshas CPU/memory/connections charts but no pull-based external feed.
Available paths if you ever want pg_stat_ shipped to SigNoz:*
postgres_exportercontainer — a Prometheus exporter that scrapespg_stat_*views via the pooler URL. Run as a service-kind apps entry, add aprometheusreceiver to the collector that scrapes its:9187/metrics. ~30 min of setup. Adds another moving piece + an SQL role withpg_monitorgranted.- OTel Collector
postgresqlreceiver —postgresqlreceiverinotelcol-contrib. Native, no extra container. Same pooler URL. Cleaner but requires a config-rewrite +make configureredeploy. - App-side spans — the polymarket-fetch services already emit sqlx queries as spans via
tracing-opentelemetry. SigNoz Services → DB Calls panel surfaces per-query timing, error rates, and rate-of-calls. This is what we have today.
Current decision: stick with #3 (app-side spans). The DB-call surface in SigNoz already covers slow-query identification (the bot’s WITH targets AS (...) query showing up as a 1.6s span every 2min is what flagged it), and per-table query rate is observable through trace attributes. Adding (1) or (2) is +ev only if we start needing connection-pool saturation alerts, transaction-rate dashboards, or per-table I/O — none of which the current workload demands.
Re-evaluate if any of:
- Bot/scheduled-run start hitting
too many connectionserrors (current Supabase pooler limit is 60 for session mode). - Query latency p99 climbs past 5s without an obvious code change.
- We need to alert on table bloat or index health.
Operational checklist when adding a new app
- Drop the
observability.rssnippet (or equivalent SDK init) in the app crate; callinit()frommain. - Deploy via the apps role (manifest entry with default telemetry enabled — no extra config needed).
- Wait ~30s for the first batch.
- Verify:
servicesview in SigNoz lists the newOTEL_SERVICE_NAMEvalue with non-zero callRate. - Logs view: filter
service.name=<name>— first batched logs appear within ~30s of firstinfo!()/warn!()/error!().
If signals don’t show up:
| Symptom | Likely cause | Fix |
|---|---|---|
| Service not in services list | OTEL_EXPORTER_OTLP_ENDPOINT not reaching collector | docker exec apps-<name> env | grep OTEL should show host.docker.internal:4317; if missing, the apps role didn’t render the env block (check item.telemetry not set to false). |
| Service in list but call rate zero | App not using #[tracing::instrument] or equivalent spans | Without spans, only the implicit “root span per request” emits. Wrap your work in tracing::span! / #[instrument]. |
otelcol_exporter_send_failed_* non-zero | SigNoz ingestion key invalid, network blocked, or rate-limited | Check secrets/signoz_ingestion_key; check otelcol-contrib journal journalctl -u otelcol-contrib -n 50. |
| Logs missing but traces present | opentelemetry-appender-tracing bridge not initialized | Confirm OpenTelemetryTracingBridge::new(&logger_provider) is one of the layers passed to tracing_subscriber::registry(). |
Related
- gcp-app-deploy-design — Spec 2a, the container deploy stack this telemetry rides on.
- gcp-vm-provisioning-design — Spec 1, the VM and base OTel collector setup (host metrics only).
- gcp-terraform-ansible-gotchas — gotchas #9 (
:8888/metricscanonical signal), #10 (OTel v0.152 renames). - agent-guide-configure-app-deploy — operator-facing procedure for deploying a new app.
- spec-1-deployment-complete — Spec 1 retro.
- spec-2-roadmap — Spec 2 umbrella.
- levandor-infra — project index.