Skip to content

Receipt-processing OpenTelemetry

BrownRook receipt workers emit application-defined traces and metrics over OTLP/gRPC to an in-cluster OpenTelemetry Collector. Structured stdout logs keep the same trace and OCR database identifiers, so operators can move between a failed log, its trace, and the persisted OCR-learning row.

The deployment is opt-in. The existing deploy/rabbitmq-private-logging path remains unchanged until the external telemetry backend and certificate inputs are ready.

Telemetry flow

flowchart LR
    worker[receipt-worker SDK]
    logs[JSON stdout]
    collector[OpenTelemetry Collector]
    wal[(persistent sending queue)]
    backend[Alloy telemetry gateway]
    prometheus[Prometheus]
    grafana[Grafana]
    rabbit[RabbitMQ metrics]
    fluent[Fluent Bit metrics]
    database[(PostgreSQL OCR learning)]
    logCollectors[Fluent Bit and Alloy]
    loki[Loki]

    worker -->|OTLP/gRPC mTLS| collector
    collector --> wal -->|OTLP/gRPC mTLS| backend
    rabbit --> collector
    fluent --> collector
    backend -->|normalized metrics| prometheus --> grafana
    prometheus -.->|scrape loopback /metrics| backend
    worker -->|run_uuid and pass_id| database
    worker --> logs --> logCollectors --> loki

The Python SDK uses a bounded BatchSpanProcessor and periodic metric reader. Exporter work runs on SDK background threads; a missing or unavailable Collector cannot block, crash, or roll back receipt processing. The Collector uses a 2,048-element persistent sending queue and unlimited elapsed retry time for the backend hop. A full queue can still drop new telemetry, and a worker crash can lose data still held in its in-memory SDK queue.

Trace contract

One receipt.process root span covers a database-atomic receipt attempt. The following operation spans share its trace:

Span Purpose
receipt.cache.lookup OCR cache decision and hit/miss state
receipt.page.processing One source page's OCR and selection work
receipt.ocr.pass One engine, DPI, PSM, and image-variant pass
receipt.consensus.selection Base-pass choice and line consensus
receipt.parsing Receipt field and line-item extraction
receipt.reconciliation Match to an existing canonical expense
receipt.persistence Evidence, OCR learning, attachment, and canonical writes

run_uuid is created before a cache-miss OCR run. pass_id is assigned before the corresponding engine invocation. The exact pair is attached to the OCR span and later inserted into budget.receipt_ocr_passes. On a cache hit, the root adopts the cached run UUID so it still correlates to the previously persisted OCR run.

Each engine return immediately records the pass count/duration metrics and an OCR pass completed log. Each OCR pass span records ocr.engine, ocr.engine.type, ocr.dpi, ocr.psm, ocr.variant, ocr.status, ocr.duration, ocr.selected_base, ocr.structural_score, ocr.consensus.line_coverage, and ocr.consensus.coverage_ratio. The span is started with the engine operation, enriched as soon as that page's consensus is known, and ended immediately. Parsing and database work continue after the span is available for export.

The root and pass spans retain source and run identifiers because traces are the intended high-cardinality correlation surface. Those values are excluded from metrics.

Metric contract

Name Type Unit Allowed dimensions
brownrook.receipt.processed Counter {receipt} status
brownrook.receipt.processing.duration Histogram s status
brownrook.receipt.cache.lookup Counter {lookup} result (hit or miss)
brownrook.receipt.ocr.pass.completed Counter {pass} engine, engine_type, dpi, psm, variant, status
brownrook.receipt.ocr.pass.duration Histogram s engine, engine_type, dpi, psm, variant, status
brownrook.receipt.ocr.pass.selected Counter {pass} engine, engine_type, dpi, psm, variant, status

Receipt hashes, filenames, run_uuid, and pass_id are never metric dimensions. They remain in traces and logs. Prometheus receives the normalized metric names over loopback remote write, evaluates the KAN-88 recording and alerting rules, and supplies the provisioned Grafana receipt and OCR dashboards. Alloy copies only environment, service, and worker_host from resource metadata into application metric datapoints. Pod names and UIDs are deliberately excluded so deployments do not create unbounded time-series cardinality. Prometheus exemplars link metric points back to Tempo traces.

The Collector also scrapes its own internal metrics, Fluent Bit's Prometheus endpoint, and RabbitMQ's detailed queue families. Prometheus independently scrapes Alloy's loopback-only 127.0.0.1:12345/metrics endpoint. This keeps Alloy's remote-write/WAL failures, retries, drops, and pending samples visible even when its remote-write path is unhealthy. Together these supply queue depth and age, publish/delivery/ACK/redelivery activity, and telemetry failure, retry, drop, and buffer health without adding another network-facing Prometheus endpoint.

Structured log contract

When HOME_BUDGET_STRUCTURED_LOGS=true, each worker record is one JSON object. Every object has trace_id, span_id, run_uuid, and pass_id keys; values are null outside the corresponding context. The OCR pass completed event is emitted inside its pass span, so all four are populated. Log collection remains stdout-based and continues through Fluent Bit and external Alloy.

Do not promote any of these four correlation values to Loki labels.

Application configuration

The worker reads standard OpenTelemetry variables. The committed overlay sets production defaults; change them in Git rather than application code.

Variable Committed value or behavior
HOME_BUDGET_TELEMETRY_ENABLED true in the telemetry overlay; disabled otherwise
HOME_BUDGET_STRUCTURED_LOGS true in the telemetry overlay
OTEL_EXPORTER_OTLP_ENDPOINT Collector Service using https and port 4317
OTEL_EXPORTER_OTLP_CERTIFICATE CA used to verify the Collector server
OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE Receipt-worker client certificate
OTEL_EXPORTER_OTLP_CLIENT_KEY Receipt-worker client private key
OTEL_BSP_MAX_QUEUE_SIZE 2,048 spans
OTEL_BSP_MAX_EXPORT_BATCH_SIZE 256 spans
OTEL_BSP_SCHEDULE_DELAY 1 second
OTEL_BSP_EXPORT_TIMEOUT 5 seconds
OTEL_METRIC_EXPORT_INTERVAL 5 seconds
OTEL_TRACES_SAMPLER parentbased_always_on in production

OTEL_SERVICE_NAME, OTEL_SERVICE_VERSION, and OTEL_DEPLOYMENT_ENVIRONMENT supply resource identity. Kubernetes namespace, Pod name/UID, node, and container name come from the Downward API. The SDK also honors standard signal-specific OTLP endpoint, certificate, header, compression, and timeout overrides.

Keep the application sampler at parentbased_always_on in production. The Collector's OTEL_TAIL_SAMPLING_PERCENTAGE controls baseline trace volume and defaults to 100. Its status-code policy retains ERROR traces. Because a receipt can outlive the ten-second tail decision, a late application failure also emits a short receipt.process.failure ERROR trace with the same run_uuid. This ensures the failure signal is retained even if the earlier full trace was not; the full trace remains authoritative when it was sampled.

PKI and Secrets

The two OTLP hops use separate identities:

Secret Contents Consumer
receipt-telemetry-client-tls Collector CA plus receipt-worker client certificate/key receipt worker
otel-collector-server-tls client CA plus Collector server certificate/key Collector receiver
otel-collector-backend-tls backend CA plus Collector client certificate/key Collector exporter
otel-backend backend host:port and verified server name Collector exporter

The external Alloy OTLP receiver also uses the dedicated otel-backend-server identity. Its certificate covers monitoring.idc.brownrook.net; the private key exists only in the external PKI directory and on the monitoring host.

The Collector server certificate must cover otel-collector.home-budget.svc.cluster.local; both client certificates need the TLS clientAuth extended key usage. Keep CA signing keys and leaf private keys outside Git. Store the four identities under the paths documented in ops/monitoring/README.md. The repository's secrets.example.yaml documents shape only.

The ops/monitoring playbook validates these protected files, installs the external server identity, and reconciles all four Kubernetes Secrets without printing their contents. Restrict every private input file to mode 0400 or 0600. After rotating a telemetry identity, apply the playbook and restart the receipt worker and Collector deployments so their gRPC clients and servers load the new certificate material.

Issue or rotate telemetry certificates

Perform this ceremony on the Mac with the Intermediate CA YubiKey inserted. The PIN is entered only at the p11tool or gnutls-certtool prompt; never put it in a command, environment file, log, or Git.

For initial issuance, run the checked-in helper from the repository root:

scripts/issue_telemetry_certificates.sh

It uses MONITORING_PKI_DIR when set and otherwise defaults to ~/brownrook-ca. The helper stages all artifacts, refuses to replace an existing identity directory, confirms the certificate in YubiKey slot 9C matches intermediate/intermediate_ca.crt, requests the four signatures, builds the full chains, and verifies their purposes, hostnames, lifetimes, key pairs, and permissions before installation. Use --pki-dir PATH or --provider PATH only when the environment differs from the documented defaults.

The commands below are the manual reference implemented by the helper and are also the basis for a staged renewal ceremony.

For initial issuance, clone fluent-bit-loki-client for the two client identities. They inherit its clientAuth request and GnuTLS template:

cd ~/brownrook-ca/leafs

for name in receipt-telemetry-client otel-collector-backend-client; do
  mkdir -m 0755 "$name"
  cp fluent-bit-loki-client/fluent-bit-loki-client.cnf "$name/$name.cnf"
  cp fluent-bit-loki-client/fluent-bit-loki-client.tmpl "$name/$name.tmpl"
  sed -i '' "s/^CN[[:space:]]*=.*/CN = $name/" "$name/$name.cnf"

  openssl genpkey \
    -algorithm EC \
    -pkeyopt ec_paramgen_curve:prime256v1 \
    -out "$name/$name.key"
  chmod 0600 "$name/$name.key"
  openssl req -new -sha384 \
    -config "$name/$name.cnf" \
    -key "$name/$name.key" \
    -out "$name/$name.csr"
  openssl req -in "$name/$name.csr" -noout -verify
done

Clone the existing monitoring server identity for the two server certificates. Change the CN and SAN in the OpenSSL request and the dns_name in the GnuTLS template:

cd ~/brownrook-ca/leafs

prepare_telemetry_server() {
  local name="$1"
  local dns_name="$2"

  mkdir -m 0755 "$name"
  cp monitoring/monitoring_leaf.cnf "$name/$name.cnf"
  cp monitoring/monitoring_leaf.tmpl "$name/$name.tmpl"
  sed -i '' \
    -e "s/^CN[[:space:]]*=.*/CN = $dns_name/" \
    -e "s/^DNS\\.1[[:space:]]*=.*/DNS.1 = $dns_name/" \
    "$name/$name.cnf"
  sed -i '' \
    "s/^dns_name[[:space:]]*=.*/dns_name = $dns_name/" \
    "$name/$name.tmpl"

  openssl genpkey \
    -algorithm EC \
    -pkeyopt ec_paramgen_curve:secp384r1 \
    -out "$name/$name.key"
  chmod 0600 "$name/$name.key"
  openssl req -new -sha384 \
    -config "$name/$name.cnf" \
    -key "$name/$name.key" \
    -out "$name/$name.csr"
  openssl req -in "$name/$name.csr" -noout -verify
}

prepare_telemetry_server \
  otel-backend-server \
  monitoring.idc.brownrook.net

prepare_telemetry_server \
  otel-collector-server \
  otel-collector.home-budget.svc.cluster.local

unset -f prepare_telemetry_server

Resolve the Intermediate CA private-key URI without printing it, then sign all four CSRs. This is the same gnutls-certtool ceremony used for fluent-bit-loki-client; expect a PIN prompt and YubiKey touch for each leaf:

(
set -eu
cd ~/brownrook-ca

INTERMEDIATE_CA_KEY_URI="$(
  p11tool \
    --provider /opt/homebrew/lib/libykcs11.dylib \
    --login \
    --list-all \
    --only-urls |
  grep -E 'id=%02.*type=private|type=private.*id=%02' |
  head -n 1
)"
test -n "$INTERMEDIATE_CA_KEY_URI"

for name in \
  otel-backend-server \
  receipt-telemetry-client \
  otel-collector-server \
  otel-collector-backend-client
do
  (
    cd "leafs/$name"
    gnutls-certtool \
      --ask-pass \
      --hash=SHA384 \
      --generate-certificate \
      --template="$name.tmpl" \
      --load-request="$name.csr" \
      --load-ca-certificate=../../intermediate/intermediate_ca.crt \
      --load-ca-privkey="$INTERMEDIATE_CA_KEY_URI" \
      --provider=/opt/homebrew/lib/libykcs11.dylib \
      --outfile="$name.crt"

    cat "$name.crt" ../../intermediate/intermediate_ca.crt \
      >"$name.fullchain.crt"
    chmod 0644 "$name.crt" "$name.fullchain.crt"
  )
done
)

Verify the chains, purposes, and server names before allowing Ansible to read the files:

cd ~/brownrook-ca

for name in receipt-telemetry-client otel-collector-backend-client; do
  openssl verify \
    -CAfile root/root_ca.crt \
    -untrusted intermediate/intermediate_ca.crt \
    -purpose sslclient \
    "leafs/$name/$name.crt"
done

openssl verify \
  -CAfile root/root_ca.crt \
  -untrusted intermediate/intermediate_ca.crt \
  -purpose sslserver \
  leafs/otel-backend-server/otel-backend-server.crt
openssl x509 -checkhost monitoring.idc.brownrook.net -noout \
  -in leafs/otel-backend-server/otel-backend-server.crt

openssl verify \
  -CAfile root/root_ca.crt \
  -untrusted intermediate/intermediate_ca.crt \
  -purpose sslserver \
  leafs/otel-collector-server/otel-collector-server.crt
openssl x509 \
  -checkhost otel-collector.home-budget.svc.cluster.local \
  -noout \
  -in leafs/otel-collector-server/otel-collector-server.crt

find \
  leafs/otel-backend-server \
  leafs/receipt-telemetry-client \
  leafs/otel-collector-server \
  leafs/otel-collector-backend-client \
  -maxdepth 1 -name '*.key' -exec stat -f '%Sp %N' {} \;

Every verification must report OK or a matching hostname, and every key must report mode -rw-------. Run make monitoring-gitops-check next; it repeats these checks and also verifies certificate lifetime and certificate/key pairing. For renewal, prepare and verify replacement artifacts under temporary names before replacing the current key, certificate, and full chain together.

Deploy

First render and inspect the complete opt-in overlay:

kubectl kustomize deploy/rabbitmq-private-telemetry >/tmp/ledger-telemetry.yaml
kubectl apply --dry-run=server -f /tmp/ledger-telemetry.yaml

First run make monitoring-gitops-check, make monitoring-gitops-apply, and a second check. After all four Secrets exist and the backend OTLP receiver is healthy, change the parent-owned Argo CD ledger Application path in brownrook-infra/kubernetes/gitops/apps/ledger-app.yaml from deploy/rabbitmq-private-logging to deploy/rabbitmq-private-telemetry. Merge that repository change and let the brownrook-root Application reconcile it. Do not make a direct child Application override; the parent will revert it.

Verify rollout and mTLS readiness:

kubectl --context brownrook-k3s1 -n home-budget rollout status deploy/otel-collector
kubectl --context brownrook-k3s1 -n home-budget rollout status deploy/receipt-worker
kubectl --context brownrook-k3s1 -n home-budget get pod,svc,pvc -l app=otel-collector
kubectl --context brownrook-k3s1 -n home-budget logs deploy/otel-collector --tail=100

An unauthenticated client must not be able to submit OTLP data. Confirm the receiver rejects a TLS connection without the receipt-worker client identity:

kubectl --context brownrook-k3s1 -n home-budget run otlp-no-client \
  --rm -i --restart=Never --image=curlimages/curl:8.16.0 -- \
  curl --insecure --fail https://otel-collector:4317

The exact curl failure text is not significant; a successful unauthenticated HTTP response would be a security failure. OTLP/gRPC itself is verified by the worker test below.

Synthetic OTLP demo

Use the repository helper to verify both mTLS hops and Tempo without processing a real receipt:

make otlp-demo

The helper waits for the production Collector, mounts the receipt-telemetry-client-tls Secret into a non-root, disposable telemetrygen Pod, sends one trace over verified OTLP/gRPC mTLS, and deletes the Pod. The image is pinned by digest, the service-account token is not mounted, and no certificate or key value is placed on the command line.

After the Collector's ten-second tail-sampling window, copy the TraceQL query printed by the helper into Grafana Explore with the tempo datasource. A successful result contains one trace and two spans. To use a predictable service name, run:

OTLP_DEMO_SERVICE=home-budget-otlp-demo make otlp-demo

Then query:

{ resource.service.name = "home-budget-otlp-demo" }

The demo requires the deploy/rabbitmq-private-telemetry Argo CD overlay and the four Secrets created by make monitoring-gitops-apply. Override KUBE_CONTEXT, KUBE_NAMESPACE, OTLP_DEMO_CLIENT_SECRET, or OTLP_DEMO_ENDPOINT only when testing a different environment.

End-to-end verification

Choose one non-sensitive receipt reference and start a reprocess request while following worker logs:

kubectl --context brownrook-k3s1 -n home-budget logs deploy/receipt-worker -f | \
  jq 'select(.message == "OCR pass completed") | {trace_id,span_id,run_uuid,pass_id,status}'

ledger receipts reprocess YYYY-MM-DD/receipt.pdf

Before the root run finishes, the log must show an OCR pass with non-null correlation fields. Within the five-second metric export interval, query the backend for brownrook_receipt_ocr_pass_completed_total (the Prometheus-normalized form of brownrook.receipt.ocr.pass.completed). With the default 100-percent Collector sampling, query traces for receipt.ocr.pass after its ten-second decision window and copy its run_uuid and pass_id.

Confirm the same identity exists in PostgreSQL:

SELECT run_uuid, pass_id, engine, dpi, psm, variant, status,
       selected_base, consensus_coverage_ratio
  FROM budget.receipt_ocr_passes
 WHERE run_uuid = :'run_uuid'
   AND pass_id = :'pass_id';

The repository test performs the ordering assertion with an in-memory span backend:

pytest -q -m 'not integration' tests/test_opentelemetry.py

It verifies that the pass has already reached the exporter while receipt.process remains unfinished.

Failure and recovery drill

Use a disposable receipt reprocess request. Scale the Collector down, start the request, and confirm the worker continues through OCR and database commit:

kubectl --context brownrook-k3s1 -n home-budget scale deploy/otel-collector --replicas=0
ledger receipts reprocess YYYY-MM-DD/receipt.pdf
kubectl --context brownrook-k3s1 -n home-budget logs deploy/receipt-worker -f

The worker should report a normal succeeded or review_required outcome even while the SDK reports export retries. Restore the Git-declared replica count by syncing Argo CD, then submit a fresh request and confirm new trace and metric data appears. The bounded SDK queue can lose old data during a long Collector outage; recovery means receipt processing never stopped and subsequent telemetry resumes.

For a backend-only outage, leave the Collector running and stop or firewall the test backend. Confirm Collector logs show retries, its Pod remains ready, and the otel-collector-storage PVC remains bound. Restore the backend and confirm the persistent queue drains before declaring the drill successful.

To test Alloy's final metric-delivery hop independently, stop only Prometheus on the monitoring host, process a disposable receipt, and inspect Alloy's loopback metrics while the receiver is unavailable:

ssh paul@192.168.2.202
sudo systemctl stop prometheus
curl -fsS http://127.0.0.1:12345/metrics | \
  grep -E '^prometheus_remote_storage_(samples_pending|samples_retries_total)'

At least one retry counter or pending-sample gauge for component_id="prometheus.remote_write.local_prometheus" must increase without interrupting receipt processing. Restore the receiver and confirm Alloy drains its WAL and Prometheus resumes both the direct Alloy scrape and application metric ingestion:

sudo systemctl start prometheus
curl -fsS http://127.0.0.1:9090/-/ready
curl -fsS \
  'http://127.0.0.1:9090/api/v1/query?query=up%7Bjob%3D%22alloy%22%7D' | \
  jq -e '.data.result[0].value[1] == "1"'
curl -fsS \
  'http://127.0.0.1:9090/api/v1/query?query=brownrook_receipt_processed_total' | \
  jq -e '.data.result | length > 0'

After the backlog drains, prometheus_remote_storage_samples_pending{component_id="prometheus.remote_write.local_prometheus"} must return to zero. The retry counter is cumulative and is expected to retain the interruption as evidence.

Rollback

Change the parent-owned Argo CD Application path back to deploy/rabbitmq-private-logging, merge, and sync. This removes the Collector and worker telemetry configuration without changing stdout log collection or receipt data. Retain the Collector PVC until rollback acceptance in case its sending queue still contains telemetry. Delete it only as a separate, explicitly reviewed cleanup after confirming no recovery is required.

Application-only emergency rollback is also possible by setting HOME_BUDGET_TELEMETRY_ENABLED=false and restarting the worker. Prefer the GitOps path so the change is durable.

Native macOS workers

Local background workers use OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf and OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318. The local Alloy agent forwards traces and metrics over mTLS to the LAN monitoring endpoint. Keeping gRPC out of the Python OCR worker avoids inherited gRPC polling state during subprocess creation. The application's default transport remains gRPC; per-signal OTEL_EXPORTER_OTLP_TRACES_PROTOCOL and OTEL_EXPORTER_OTLP_METRICS_PROTOCOL override the common protocol when needed.

Use make dev-telemetry-restart after updating the exporter dependencies and Alloy configuration. This reloads local telemetry settings and restarts all configured receipt workers and the collector. See local development.