EMS Observability Architecture
This page explains how Keep collects metrics, logs, and traces in the local OpenTelemetry stack (docker-compose-with-otel.yaml). It also explains how to extend custom metrics and custom traces at code and collector levels.
Architecture overview ¶
The current stack uses these components:
keep-backend-devemits OpenTelemetry telemetryotel-collectorreceives and routes telemetry by signalprometheusstores and serves metrics (Kubed Platform)tempostores and serves traces (Self Hosted)lokistores and serves logs (Kubed Platform)grafanaqueries Prometheus, Tempo, and Loki (LMA)
flowchart LR
A[keep-backend-dev\nOTel SDK in keep/api/observability.py] --> B[otel-collector\notlp receiver]
B --> C[metrics pipeline] --> D[prometheus exporter :9100] --> E[prometheus]
B --> F[traces pipeline] --> G[otlp exporter -> tempo:4317] --> H[tempo]
B --> I[logs pipeline] --> J[loki exporter -> /loki/api/v1/push] --> K[loki]
E --> L[grafana datasource: Prometheus]
H --> M[grafana datasource: Tempo]
K --> N[grafana datasource: Loki]
Metrics flow ¶
Keep creates metrics in the OpenTelemetry Python SDK and exports them to the collector over OTLP gRPC.
flowchart TD
A[keep/api/api.py\nInstrumentator metric namespace: keep] --> B[keep/api/observability.py\nMeterProvider + OTLPMetricExporter]
B -->|OTLP gRPC :4317| C[otel-collector receiver: otlp]
C --> D["metrics pipeline\nreceivers:[otlp]\nprocessors:[]\nexporters:[prometheus]"]
D --> E[collector prometheus endpoint :9100]
E --> F[prometheus scrape job: wordpress]
F --> G[grafana prometheus datasource]
T1[tempo metrics_generator\nservice-graphs/span-metrics] -->|remote_write| F
Key code and config:
- App metrics instrumentation:
keep/api/api.py(KEEP_METRICS,Instrumentator(..., metric_namespace="keep"))keep/api/observability.py(MeterProvider,OTLPMetricExporter)
- App endpoint env:
docker-compose-with-otel.yamlOTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://otel-collector:4317docker-compose-with-otel.yamlMETRIC_OTEL_ENABLED=true
- Collector metrics pipeline:
otel-shared/otel-collector-config.yaml(service.pipelines.metrics)- Prometheus exporter endpoint
0.0.0.0:9100
- Prometheus scrape:
otel-shared/prometheus.yaml(job_name: 'wordpress', targetotel-collector:9100)
- Tempo generated metrics:
otel-shared/tempo.yaml(metrics_generator, remote write to Prometheus)
Trace flow ¶
Keep creates spans through FastAPI and Requests instrumentations and through manual spans, then exports to collector via OTLP HTTP.
flowchart TD
A[keep/api/observability.py\nTracerProvider + BatchSpanProcessor] --> B[FastAPIInstrumentor + RequestsInstrumentor]
B -->|OTLP HTTP :4318 /v1/traces| C[otel-collector receiver: otlp]
C --> D["traces pipeline\nprocessors:[memory_limiter,batch]"]
D -->|otlp exporter| E[tempo distributor receiver :4317]
E --> F[tempo ingester + local storage\n/tmp/tempo/wal + /tmp/tempo/blocks]
F --> G[tempo query API :3200]
G --> H[grafana tempo datasource]
Key code and config:
- App trace setup:
keep/api/observability.py(TracerProvider,BatchSpanProcessor)keep/api/observability.py(FastAPIInstrumentor,RequestsInstrumentor)
- App endpoint env:
docker-compose-with-otel.yamlOTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://otel-collector:4318/v1/traces
- Collector traces pipeline:
otel-shared/otel-collector-config.yaml(service.pipelines.traces)- OTLP exporter to
tempo:4317
- Tempo receiver and storage:
otel-shared/tempo.yaml(OTLP receiver on0.0.0.0:4317/4318)
Log flow ¶
Keep exports logs through OpenTelemetry logs SDK and collector sends them to Loki.
flowchart TD
A[python logging + LoggingInstrumentor] --> B[LoggerProvider + BatchLogRecordProcessor]
B -->|OTLP HTTP :4318 /v1/logs| C[otel-collector receiver: otlp]
C --> D["logs pipeline\nprocessors:[memory_limiter,batch,attributes]"]
D --> E[loki exporter\nhttp://loki:3100/loki/api/v1/push]
E --> F[grafana loki datasource]
V1[vector docker_logs source] -. configured path, not active in current podman permissions .-> E
Key code and config:
- App logs setup:
keep/api/observability.py(LoggerProvider,BatchLogRecordProcessor)keep/api/observability.py(LoggingHandler(level=logging.INFO, ...))keep/api/observability.py(LoggingInstrumentor().instrument())
- App endpoint env:
docker-compose-with-otel.yamlOTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://otel-collector:4318/v1/logs
- Collector logs pipeline:
otel-shared/otel-collector-config.yaml(service.pipelines.logs)attributesprocessor adds Loki attributes (loki.attribute.labels,loki.format)
- Vector alternative path:
otel-shared/vector.toml
Where telemetry initialization happens ¶
Both API and ARQ worker initialize observability when KEEP_OTEL_ENABLED=true:
keep/api/api.py->keep.api.observability.setup(app)keep/api/arq_worker_gunicorn.py->keep.api.observability.setup(app)
How to extend custom metrics ¶
Use this path when you need domain metrics like workflow counts, provider latency, or alert classification rates.
Step 1: create a meter and instruments in backend code ¶
Add code in the module where the business event happens. Keep metric names and attributes stable.
from opentelemetry import metrics
meter = metrics.get_meter("keep.custom", version="1.0.0")
workflow_run_counter = meter.create_counter(
name="keep_workflow_runs_total",
description="Total workflow runs",
unit="1",
)
workflow_duration = meter.create_histogram(
name="keep_workflow_duration_seconds",
description="Workflow run duration",
unit="s",
)
# usage
workflow_run_counter.add(1, {"tenant_id": tenant_id, "status": "success"})
workflow_duration.record(elapsed_seconds, {"tenant_id": tenant_id})
Step 2: validate endpoint and exporter settings ¶
Confirm these are set for the running backend container:
METRIC_OTEL_ENABLED=trueOTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://otel-collector:4317
Step 3: expose and query in Prometheus and Grafana ¶
No collector change is needed for basic passthrough because service.pipelines.metrics already exports to Prometheus.
Query in Grafana with PromQL, for example:
sum(rate(keep_workflow_runs_total[5m])) by (status)
Step 4: add metric processing only if needed ¶
If you need filtering, renaming, or attribute normalization, add processors in otel-shared/otel-collector-config.yaml and include them in service.pipelines.metrics.processors.
How to extend custom traces ¶
Use this path when you need to trace specific business operations in more detail.
Step 1: create manual spans around business logic ¶
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("workflow.execute") as span:
span.set_attribute("keep.tenant_id", tenant_id)
span.set_attribute("keep.workflow_id", workflow_id)
try:
execute_workflow()
span.set_attribute("keep.result", "success")
except Exception as exc:
span.record_exception(exc)
span.set_attribute("keep.result", "error")
raise
Step 2: use semantic attributes and low-cardinality tags ¶
Use consistent keys and avoid high-cardinality values where possible:
- Prefer IDs, enums, booleans
- Avoid full SQL, raw payloads, long free-form strings
Step 3: verify routing and storage ¶
The existing path is already wired:
- App ->
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT - Collector
service.pipelines.traces - Tempo OTLP receiver (
tempo:4317)
Step 4: add collector processors for trace policy ¶
When you need advanced behavior (sampling, redaction, enrichment), add processors to collector and include them in the traces pipeline before export.
Typical examples:
tail_samplingfor cost controlattributesfor sanitization or normalizationfilterto drop noise
Troubleshooting quick checks ¶
Use these checks to isolate issues by stage:
- App emits telemetry:
- Check
keep-backend-devlogs for OTLP endpoint lines.
- Check
- Collector receives and exports:
- Check
otel-collectorlogs for exporter errors.
- Check
- Backend stores telemetry:
- Tempo: query tags API
- Loki: query labels API
- Prometheus: query metric names
Next steps ¶
If you want, add a dedicated collector profile for production-like behavior with sampling and stricter attribute controls, then keep this current profile as the local debug profile.
Hands-on example: custom metrics and custom trace ¶
This repository now includes a runnable example endpoint that emits custom metrics and a custom trace span:
- Route:
GET /observability-examples/custom - Code:
keep/api/routes/observability_examples.py - Feature toggle:
KEEP_OBSERVABILITY_EXAMPLE_ENABLED(defaulttrue, setfalseto disable)
1. Generate telemetry ¶
Call the endpoint a few times to generate both success and error samples:
curl "http://localhost:8080/observability-examples/custom?tenant=demo&work_ms=180"
curl "http://localhost:8080/observability-examples/custom?tenant=demo&work_ms=320"
curl "http://localhost:8080/observability-examples/custom?tenant=demo&work_ms=120&fail=true"
The endpoint emits these custom metrics:
keephq_demo_requestskeephq_demo_failureskeephq_demo_duration_ms
Because the collector Prometheus exporter is configured with namespace: keep, the names you query in Prometheus and Grafana become:
keep_keephq_demo_requestskeep_keephq_demo_failureskeep_keephq_demo_duration_ms_bucket|sum|count
It also emits this custom span name:
keephq.demo.custom_trace
2. Verify custom metrics in Grafana ¶
Open Grafana Explore and choose Prometheus datasource.
Use these PromQL queries:
sum(keep_keephq_demo_requests) by (status)
sum(keep_keephq_demo_failures) by (tenant)
histogram_quantile(0.95, sum(rate(keep_keephq_demo_duration_ms_bucket[5m])) by (le))
To discover names quickly:
curl -s 'http://localhost:9090/api/v1/label/__name__/values' | grep demo
3. Verify custom traces in Grafana ¶
Open Grafana Explore and choose Tempo datasource.
Use this TraceQL query:
{ name = "keephq.demo.custom_trace" }
You can also filter by your custom span attributes:
{ name = "keephq.demo.custom_trace" && span.keep.demo.tenant = "demo" }
4. Troubleshooting this example ¶
If you do not see data:
- Confirm the endpoint is not disabled via
KEEP_OBSERVABILITY_EXAMPLE_ENABLED=false. - Confirm OTLP endpoints are set in
docker-compose-with-otel.yaml. - Check
otel-collectorlogs for exporter errors. - Re-run the endpoint to generate fresh samples.
Reference ¶
Code files mentioned in this page are all under Monitoring/keephq.