Notes / Projects / App Signal Forge / Architecture

Architecture Overview

Signal Forge's topology, service communication, trace propagation, and per-signal pipeline flow across local and Grafana Cloud deployment modes.

Updated July 10, 2026 · §202607091847-13 ·

Architecture Overview

System topology

flowchart TD
    subgraph otelns["Namespace: otel-lab"]
        spa["Angular SPA<br/>Faro RUM<br/>nginx :80"]
        gw["gateway-api<br/>.NET 8<br/>MySQL"]
        oa["order-api<br/>.NET 8 (gRPC)<br/>PostgreSQL"]
        ns["notification-svc<br/>Python<br/>Redis"]
        rmq["RabbitMQ<br/>3.13"]

        spa -- HTTP --> gw
        gw -- gRPC --> oa
        gw -- HTTP --> ns
        oa -- AMQP --> rmq
        rmq -- consume --> ns
    end

    subgraph monns["Namespace: monitoring (grafana/k8s-monitoring Helm v3.8.4)"]
        subgraph alloyrecv["alloy-receiver (DaemonSet)"]
            otlp["OTLP :4317/:4318"]
            faro["Faro :12347"]
            k8sattr["k8sattributes"]
            transform["env_label transform"]
            filterhz["filter(/healthz)"]
            spanm["spanmetrics connector<br/>(RED metrics)"]
            tail["tail_sampling<br/>(errors 100%, slow 100%, rest 25%)"]
            batchp["batch"]
            proc["processor"]

            otlp --> k8sattr
            faro --> k8sattr
            k8sattr --> transform
            transform -- traces --> filterhz --> spanm --> tail --> batchp
            transform -- metrics --> batchp
            transform -- logs --> proc
        end

        subgraph localb["Local"]
            jaegerL["Jaeger"]
            promL["Prometheus"]
            lokiL["Loki"]
        end
        subgraph cloudb["Grafana Cloud"]
            tempoC["Tempo"]
            mimirC["Mimir"]
            lokiC["Loki"]
        end

        batchp --> jaegerL
        batchp --> tempoC
        batchp --> promL
        batchp --> mimirC
        proc --> lokiL
        proc --> lokiC

        alogs["alloy-logs (DaemonSet)<br/>tails pod stdout → trace correlation → Loki"]
        amet["alloy-metrics (StatefulSet)<br/>kubelet/cAdvisor/KSM → Prometheus"]
        asing["alloy-singleton (Deployment)<br/>cluster events → Loki/Prometheus"]
    end

    otelns -- "All services: OTLP gRPC :4317" --> otlp

Service inventory

ServiceRuntimeRoleOwns
otel-frontendAngular 17 + nginxBrowser SPA, Faro RUM
gateway-api.NET 8 Minimal APIBFF — receives all browser callsMySQL 8 (projects)
order-api.NET 8 gRPCOrder CRUD + async eventsPostgreSQL 16 (orders)
notification-svcPython 3.12 FastAPIRabbitMQ consumer, dedup, mock emailRedis 7 (notification state)

Communication patterns

FromToProtocolOTel propagation
Browsergateway-apiHTTP/JSONFaro injects traceparent header
gateway-apiorder-apigRPC (unary + server-streaming)Auto-injected in gRPC metadata
gateway-apinotification-svcHTTP/JSONAuto-injected via HttpClient instrumentation
order-apiRabbitMQAMQP 0-9-1Manual TextMapPropagator.Inject() into message headers
RabbitMQnotification-svcAMQP 0-9-1Manual TraceContextTextMapPropagator.extract()

Trace propagation map

A single “Create Order” click produces a trace spanning five hops and three runtimes:

sequenceDiagram
    participant Browser as Browser (Faro)
    participant Gateway as gateway-api
    participant MySQL
    participant Order as order-api
    participant Postgres as PostgreSQL
    participant RabbitMQ
    participant Notif as notification-svc
    participant Redis

    Browser->>Gateway: HTTP request (traceparent in header)
    Note right of Gateway: HTTP server span
    Gateway->>MySQL: EF Core child: db.mysql
    Gateway->>Order: gRPC call (traceparent in metadata)
    Note right of Order: gRPC server span
    Order->>Postgres: EF Core child: db.postgresql
    Order-)RabbitMQ: AMQP publish (traceparent in message headers, async)
    RabbitMQ-)Notif: consume
    Note right of Notif: CONSUMER span (SpanLink to producer, same traceId)
    Notif->>Redis: Redis child: db.redis
    Notif->>Notif: send_email child span
    Gateway->>Notif: HTTP call (traceparent in header)
    Note right of Notif: HTTP server span
    Notif->>Redis: Redis child: db.redis

The RabbitMQ hop uses a [[projects/app-signal-forge/architecture/adrs/adr-spanlink-for-async-rabbitmq|SpanLink]] (not parent-child) because message processing is asynchronous and may involve retries. Both spans share the same traceId. In Jaeger this renders as a dashed arrow.

Signal flow by type

Traces

flowchart TD
    appsdk["App SDK"] -- OTLP gRPC --> alloyrecv["alloy-receiver"]
    alloyrecv --> k8sattr["k8sattributes<br/>(enrich with pod/namespace/node)"]
    k8sattr --> transform["transform<br/>(stamp deployment.environment)"]
    transform --> filterhz["filter<br/>(drop /healthz spans)"]
    filterhz --> spanm["spanmetrics connector<br/>(generate RED metrics — before sampling)"]
    spanm --> tail["tail_sampling<br/>(errors=100%, slow&gt;2s=100%, rest=25%)"]
    tail --> batchp["batch"]
    batchp --> dest["Jaeger (local) and/or<br/>Grafana Cloud Tempo"]

Metrics

flowchart TD
    appsdk2["App SDK"] -- OTLP gRPC --> alloyrecv2["alloy-receiver"]
    alloyrecv2 --> kt2["k8sattributes + transform"]
    kt2 --> batch2["batch"]
    batch2 -- prometheus.remote_write --> prom2["Prometheus (local)"]
    batch2 -- OTLP HTTP --> mimir2["Grafana Cloud Mimir"]

    amet2["alloy-metrics (StatefulSet)"] --> scrape2["prometheus.scrape<br/>(kubelet, cAdvisor, kube-state-metrics, node-exporter)"]
    scrape2 --> prom2
    scrape2 --> mimir2

Logs

flowchart TD
    podstdout["Pod stdout (JSON)"] --> alloylogs3["alloy-logs<br/>(node-level tailing)"]
    alloylogs3 --> lokisource3["loki.source.kubernetes"]
    lokisource3 --> stagejson3["loki.process: stage.json<br/>(extract TraceId/SpanId)"]
    stagejson3 --> stagemeta3["loki.process: stage.structured_metadata<br/>(attach trace_id/span_id)"]
    stagemeta3 --> lokiwrite3["loki.write"]
    lokiwrite3 --> dest3["Loki (local) or<br/>Grafana Cloud Loki"]

Note: OTEL_LOGS_EXPORTER=none is set on all services. Logs travel via node-level tailing, not OTLP push. This is the production pattern for high-volume log shipping.

Browser RUM

flowchart TD
    spa4["Angular SPA"] -- HTTP --> faro4["alloy-receiver<br/>faro.receiver :12347"]
    faro4 -- traces --> k8s4["k8sattributes pipeline<br/>(same as above)"]
    faro4 -- logs --> lokiwrite4["loki.write<br/>(directly, bypassing OTel pipeline)"]

Deployment modes

ModeCommandBackendsUse case
Local (default)./deploy-local.shJaeger, Prometheus, Loki, Grafana in-clusterDefault — no cloud credentials needed
Cloud (opt-in)./scripts/fetch-grafana-cloud-conf-from-akv.sh then ./deploy-local.shGrafana Cloud Tempo/Mimir/LokiEnd-to-end validation with remote storage

The Alloy collector configuration is separate per mode:

  • Cloud: k8s/monitoring/grafana-helm/values-cloud.yaml.tmpl (Helm values rendered by deploy-local.sh)
  • Local: k8s/monitoring/grafana/local/configmap.yaml (hand-rolled DaemonSet — reference artifact)

See CLAUDE.md for the full command reference and safety checks built into deploy-local.sh.

Port map (after ./deploy-local.sh)

URLService
http://localhost:8080Angular SPA + API (via Traefik ingress)
http://localhost:16686Jaeger UI
http://localhost:3000Grafana (admin/admin)
http://localhost:9090Prometheus
http://localhost:15672RabbitMQ Management (signalforge/guest)
kubectl port-forward svc/grafana-k8s-alloy-receiver 12345 -n monitoringhttp://localhost:12345Alloy pipeline debug UI

Local graph

Full graph →

Linked from 7 notes

Signal Forge ADR-008: Dead Letter Queue for poison message handling

Routes messages that exceed retry limits or are NACKed without requeue to a RabbitMQ DLQ, preventing infinite retry loops from starving the consumer.

Signal Forge ADR-010: gRPC server-streaming via AsAsyncEnumerable (not ToListAsync)

Streams order rows to the gRPC client directly from the PostgreSQL cursor via AsAsyncEnumerable, keeping memory usage O(1) regardless of result set size.

Signal Forge ADR-004: Helm-managed Alloy stack (grafana/k8s-monitoring)

Standardizes on the grafana/k8s-monitoring Helm chart's five-role Alloy topology, keeping the hand-rolled DaemonSet only as a non-deployed reference.

Signal Forge ADR-009: K8s attribute enrichment at collector (not in SDK)

Adds Kubernetes pod/namespace/deployment attributes via Alloy's k8sattributes processor at the collector, keeping application SDKs Kubernetes-agnostic.

Signal Forge ADR-003: Span metrics generated before tail sampling

Places the spanmetrics connector ahead of tail_sampling so RED metrics reflect all traffic instead of only the ~25% of traces that survive sampling.

Signal Forge ADR-002: SpanLink for async RabbitMQ propagation (not parent-child)

Uses a SpanLink, not a parent-child span relationship, to connect RabbitMQ consumer spans back to the producer span across async, retry-prone delivery.

SignalForge Documentation

Documentation hub for the SignalForge OTel Microservices Validation Lab — architecture, services, API, deployment, observability, and operations.