Observability

Health endpoints

ServiceEndpointReturns
appGET /api/health{"status":"ok","timestamp":"..."}
realtimeGET /health on port 3002{"status":"ok","timestamp":"...","connections":0}

/api/health is a liveness signal only. It returns 200 as long as the process is serving HTTP — it does not check the database, Redis, or object storage. A healthy response does not mean the app can serve traffic successfully, so do not treat it as a dependency check. Verify dependencies with the smoke test instead.

Kubernetes probes

The chart ships probes tuned for a Next.js cold start. Defaults for the app:

ProbePathBudget
startupProbe/60 × 5s = 5 minutes to become ready
livenessProbe/6 × 30s = 180s of failure before restart
readinessProbe/3 × 10s = ~30s to shift traffic

Realtime uses /health on port 3002 with a 150-second startup budget.

The generous startup budget matters: a cold Next.js start on a large bundle can take minutes, and a tighter liveness probe will restart the pod mid-boot in a loop. If you customize probes, keep the startup budget well above your observed cold-start time.

app:
  startupProbe:
    httpGet:
      path: /
      port: 3000
    periodSeconds: 5
    failureThreshold: 60

Logs

Both services log structured JSON to stdout. Collect them with whatever you already run — Fluent Bit, Vector, Datadog Agent, Loki.

In production builds the logger defaults to ERROR, and the Helm chart does not set LOG_LEVEL for the app or realtime. Until you raise it, the only thing in the logs is errors — which is why a healthy-looking deployment can appear to log nothing at all. Set LOG_LEVEL: "INFO" while commissioning a deployment or debugging.

kubectl logs -n simstudio -l app.kubernetes.io/component=app --tail=200 -f
kubectl logs -n simstudio -l app.kubernetes.io/component=realtime --tail=200 -f
kubectl logs -n simstudio deploy/sim-app -c migrations --tail=100
docker compose -f docker-compose.prod.yml logs -f simstudio

Every API request carries a request ID that appears in all log lines for that request — the fastest way to reconstruct a failing call.

Workflow execution logs are a separate, product-level surface stored in the database and visible in the Logs view of the app. They are not the same as container logs: use container logs for infrastructure problems and the Logs view for workflow behavior.

Redacting PII from logs

Enable the PII service and log redaction if execution logs may contain sensitive data:

pii:
  enabled: true
app:
  env:
    INTERNAL_API_BASE_URL: "http://sim-app.simstudio.svc.cluster.local:3000"

See Security for the INTERNAL_API_BASE_URL requirement — the path fails closed without a cluster-reachable value.

Telemetry

Whether telemetry runs depends on how you deploy. A Docker Compose or source deployment enables server telemetry unless you turn it off, so a deployment with an egress policy should decide about this deliberately rather than inherit the default.

Telemetry has two halves that are decided at different times.

HalfWhen it is decidedDefault
Server SDKRuntimeOff on Helm (app.envDefaults sets NEXT_TELEMETRY_DISABLED: "1"); on for Docker Compose and source runs, which set nothing
BrowserServer setting at page render, then browser preferencesDisabled when NEXT_TELEMETRY_DISABLED=1; otherwise waits for session resolution and the signed-in user's saved preference. Hosted Sim also requires the applicable analytics permission.

Neither half gates POST /api/telemetry, the relay that forwards browser events. See the warning below.

When the server SDK runs it exports three OTLP signals to the same base endpoint: traces at /v1/traces, metrics at /v1/metrics, and application log records at /v1/logs. Sim's own telemetry is scoped to feature-usage statistics, error rates, performance metrics, and AI/LLM operation traces — not workflow content or outputs, API keys, or geolocation.

The log stream is different in kind: it carries every line at or above LOG_LEVEL with its structured metadata, error messages and stack traces, which can include user ids, emails and URLs. That matters if you raise LOG_LEVEL as suggested above, and it is a reason to point the exporter at your own collector rather than leaving it at the default.

NEXT_TELEMETRY_DISABLED=1 stops the server's OpenTelemetry SDK and disables browser collection on newly loaded pages. It does not affect Trigger.dev task telemetry, configured separately below. The Settings → General → Privacy → Allow browser telemetry toggle stops optional browser performance and error diagnostics, discards queued events, and remembers the choice. It does not stop server spans.

On Helm, NEXT_TELEMETRY_DISABLED is what you must clear before any tracing works — including tracing to your own collector. Override it with null to remove the key entirely.

app:
  envDefaults:
    NEXT_TELEMETRY_DISABLED: null

Tracing

The chart can deploy an OpenTelemetry Collector for you:

telemetry:
  enabled: true

telemetry.enabled: true deploys the collector and injects OTEL_EXPORTER_OTLP_ENDPOINT pointing at it, but the app still has NEXT_TELEMETRY_DISABLED: "1" from app.envDefaults. The collector runs and receives nothing until you clear that key as shown above. Enabling telemetry is two changes, not one.

Or point the app at a collector you already run:

VariablePurpose
OTEL_EXPORTER_OTLP_ENDPOINTCollector endpoint (OTLP over HTTP)
OTEL_EXPORTER_OTLP_HEADERSAuth headers, key=value comma-separated
TELEMETRY_ENDPOINTLegacy endpoint variable, still honored
TELEMETRY_SAMPLING_RATIOSampling ratio, 01
OTEL_TRACES_SAMPLER_ARGSampling ratio, used only when TELEMETRY_SAMPLING_RATIO is unset
OTEL_DEPLOYMENT_ENVIRONMENTEnvironment label on emitted spans

The endpoint is resolved by precedence, first match wins: OTEL_EXPORTER_OTLP_ENDPOINT, then TELEMETRY_ENDPOINT, then the endpoint in apps/sim/telemetry.config.ts. This precedence governs the server SDK only — the /api/telemetry relay reads TELEMETRY_ENDPOINT and nothing else. Sampling resolves the same way: TELEMETRY_SAMPLING_RATIO, then OTEL_TRACES_SAMPLER_ARG, defaulting to 1.0. Editing telemetry.config.ts only changes the last fallback, so an environment variable set anywhere in your stack silently wins over it.

OTEL_SERVICE_NAME is not read. The chart sets it to sim-app when telemetry.enabled is true, but the app hardcodes its own service name, so spans arrive under the literal value mothership. Filter on that, not on sim-app.

If you enable the chart's Jaeger export, point telemetry.jaeger.endpoint at Jaeger's OTLP gRPC port (4317) — the collector exports over OTLP.

Trigger.dev task telemetry

GRAFANA_OTLP_ENDPOINT, GRAFANA_OTLP_HEADERS, and GRAFANA_DEPLOYMENT_ENVIRONMENT are read only by apps/sim/trigger.config.ts. They route Trigger.dev background-task telemetry to Grafana Cloud and have no effect on the app's own OTLP export — for that, use OTEL_EXPORTER_OTLP_ENDPOINT above.

VariablePurpose
GRAFANA_OTLP_ENDPOINTGrafana OTLP gateway base URL
GRAFANA_OTLP_HEADERSe.g. Authorization=Basic <base64(instanceId:token)>
GRAFANA_DEPLOYMENT_ENVIRONMENTDeployment tier label

All three or none. Setting one or two throws at startup with a message naming them, so a partially configured worker will not boot.

Metrics

The default app and realtime images do not expose a /metrics endpoint. The chart's monitoring.serviceMonitor option exists for builds that do — enabling it against the stock images produces a ServiceMonitor that scrapes nothing.

Until an application metrics endpoint ships, build alerting from the signals that do exist:

  • Kubernetes state — pod restarts, CrashLoopBackOff, OOMKills, replica count vs desired, PVC utilization (kube-state-metrics). For a point-in-time read on resource pressure, kubectl top pods -n simstudio shows current CPU and memory usage — compare it against the limits from kubectl describe pod. It is the first thing to check when a pod is being OOMKilled.
  • Ingress/load balancer — request rate, 5xx rate, p99 latency, websocket connection count.
  • PostgreSQL — connection count vs max_connections, replication lag, disk usage, long-running queries.
  • Redis — memory usage, evictions, connected clients.
  • CronJobs — last successful completion per job.

CloudWatch metrics

Sim's hosted-key metrics recorder arms itself whenever AWS_ACCESS_KEY_ID is present, but every call site is additionally gated on the deployment being Sim Cloud. A self-hosted deployment emits no PutMetricData calls, even with AWS credentials configured for S3. If you want defence in depth against that gate ever changing, deny cloudwatch:PutMetricData on the IAM identity behind those credentials.

What to alert on

AlertWhy it matters
App pod restart loop / OOMKilledMemory is the constraining resource; OOMKills mean executions are dying mid-run
A CronJob has not succeeded within its expected interval plus a chosen grace periodScheduled workflows and polling triggers may have stopped. Choose a threshold for each job based on its schedule and expected runtime
Ingress 5xx rate above baselineBroad user impact
Postgres connections above 80% of max_connectionsNext replica or traffic spike will start failing
Postgres disk above 80%Knowledge base embeddings grow steadily
Redis unreachableLive collaboration and status updates stop, without app errors
Certificate expiry within 14 daysEspecially with manually managed certs
Object storage 4xx/5xx rateBroken uploads usually show here first

The CronJob alert is the one most deployments lack and most need. Background job failures produce no user-visible error — schedules simply stop firing. Alert on kube_cronjob_status_last_successful_time lagging, with a per-job threshold derived from that job's schedule.