The observability pillars other than metrics: structured logging into CloudWatch Logs, the dashboard layout, distributed tracing through an ADOT sidecar into X-Ray, log-based metric filters, and reading it all from Grafana.
For the metrics themselves see Monitoring and Observability; for alarms see CloudWatch alarms.
GoBridge uses Go’s slog package with a JSON handler. The
observability.CorrelationHandler wraps any slog.Handler and automatically
injects correlation_id, trace_id, and span_id from context into every log
record.
import (
"log/slog"
"os"
"github.com/mariotoffia/gobridge/observability"
"github.com/mariotoffia/gobridge/runtime"
)
jsonHandler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
logger := slog.New(observability.NewCorrelationHandler(jsonHandler))
rt := runtime.New(
runtime.WithLogger(logger),
)
Every log line is a single JSON object on stderr. The Fargate awslogs driver
sends each line to CloudWatch Logs as-is:
{
"time": "2026-04-06T10:15:32.004Z",
"level": "INFO",
"msg": "delivery completed",
"route_id": "ingest",
"envelope_id": "e-abc123",
"correlation_id": "corr-7f3a9b",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"latency_ms": 12
}
| Level | Usage |
|---|---|
ERROR |
Delivery failures, transport disconnects, store errors |
WARN |
Circuit breaker state changes, config reload warnings, DLQ writes |
INFO |
Startup, shutdown, delivery completions, lease acquisitions |
DEBUG |
Per-message flow details, header parsing, resolver decisions |
Find errors in the last hour:
fields @timestamp, @message
| filter level = "ERROR"
| sort @timestamp desc
| limit 50
Track configuration reload events:
fields @timestamp, msg, error
| filter msg like /config reload/
| sort @timestamp desc
Trace a single request by correlation ID:
fields @timestamp, msg, correlation_id, route_id
| filter correlation_id = "abc-123"
| sort @timestamp asc
Count errors by route over the last 24 hours:
fields route_id
| filter level = "ERROR"
| stats count(*) as error_count by route_id
| sort error_count desc
A well-designed dashboard provides at-a-glance health for the bridge. Organize widgets into four rows.
| Row | Widget | Metric | Type |
|---|---|---|---|
| 1 | Throughput | MessagesReceived, MessagesSent |
Line graph |
| 1 | Error Rate | RouteErrors / MessagesReceived |
Number (%) |
| 2 | Delivery Latency | DeliveryE2ELatency (Average, Maximum) |
Line graph |
| 2 | In-Flight Messages | per-route in_flight (monitor deep-health JSON) |
Gauge |
| 3 | Outbox Depth | OutboxDepth |
Area chart |
| 3 | DLQ Entries | DLQEntries |
Bar chart |
| 4 | ECS CPU/Memory | ECS CPUUtilization, MemoryUtilization |
Stacked area |
| 4 | Task Count | ECS RunningTaskCount |
Number |
In-flight count is not published to CloudWatch. The monitor deep-health
response reports it per route as in_flight (backed by RouteRunner.InFlight()
in runtime/route/runner.go); scrape that endpoint if you want it on a widget.
{
"widgets": [
{
"type": "metric",
"x": 0, "y": 0, "width": 12, "height": 6,
"properties": {
"title": "Message Throughput",
"metrics": [
["GoBridge/Runtime", "MessagesReceived", { "stat": "Sum", "period": 60 }],
["GoBridge/Runtime", "MessagesSent", { "stat": "Sum", "period": 60 }]
],
"view": "timeSeries",
"region": "eu-west-1",
"period": 60
}
},
{
"type": "metric",
"x": 12, "y": 0, "width": 12, "height": 6,
"properties": {
"title": "Error Rate (%)",
"metrics": [
[{ "expression": "(m1 / m2) * 100", "label": "Error %", "id": "e1" }],
["GoBridge/Runtime", "RouteErrors", { "stat": "Sum", "period": 300, "id": "m1", "visible": false }],
["GoBridge/Runtime", "MessagesReceived", { "stat": "Sum", "period": 300, "id": "m2", "visible": false }]
],
"view": "timeSeries",
"region": "eu-west-1"
}
},
{
"type": "metric",
"x": 0, "y": 6, "width": 12, "height": 6,
"properties": {
"title": "Delivery Latency (ms)",
"metrics": [
["GoBridge/Runtime", "DeliveryE2ELatency", { "stat": "Average", "period": 60 }],
["GoBridge/Runtime", "DeliveryE2ELatency", { "stat": "Maximum", "period": 60 }]
],
"view": "timeSeries",
"region": "eu-west-1"
}
},
{
"type": "metric",
"x": 12, "y": 6, "width": 12, "height": 6,
"properties": {
"title": "Outbox & DLQ",
"metrics": [
["GoBridge/Runtime", "OutboxDepth", { "stat": "Maximum", "period": 60 }],
["GoBridge/Runtime", "DLQEntries", { "stat": "Sum", "period": 60 }]
],
"view": "timeSeries",
"region": "eu-west-1"
}
}
]
}
GoBridge supports distributed tracing through the ports.Tracer interface. The
OTLP tracing adapter (adapters/otel/tracing/) exports spans over HTTP to any
OTLP-compatible collector.
Not wired in the
deployment/awsprofile. That deployment profile has notraces_exportersurface and provisions no OTLP collector; wiring a tracer requires a custom composition root (the wiring point is documented indeployment/aws/lib/bootstrap/registry.go).
Deploy the AWS Distro for OpenTelemetry (ADOT) collector as a sidecar container in the same Fargate task definition. The collector receives OTLP spans and forwards them to X-Ray.
# ECS task definition excerpt
containerDefinitions:
- name: gobridge
image: 123456789012.dkr.ecr.eu-west-1.amazonaws.com/gobridge:latest
# ...
- name: adot-collector
image: public.ecr.aws/aws-observability/aws-otel-collector:latest
command: ["--config=/etc/ecs/otel-config.yaml"]
portMappings:
- containerPort: 4318
protocol: tcp
import oteltracing "github.com/mariotoffia/gobridge/adapters/otel/tracing"
tracer, err := oteltracing.New(ctx,
oteltracing.WithEndpoint("http://localhost:4318"),
oteltracing.WithServiceName("gobridge"),
oteltracing.WithServiceVersion("1.0.0"),
oteltracing.WithEnvironment("production"),
oteltracing.WithSamplerRatio(0.1),
)
if err != nil {
log.Fatalf("tracer init: %v", err)
}
defer tracer.Close(ctx)
rt := runtime.New(
runtime.WithTracer(tracer),
)
The runtime creates a bridge.handleDelivery span around each message delivery.
The span carries route_id, envelope_id, and — when an ingress traceparent
is present — trace_id as attributes. W3C traceparent headers are extracted
from ingress messages and propagated through the bridge. If no trace context
exists, the tracer starts a new root span.
| Environment | Ratio | Rationale |
|---|---|---|
| Development | 1.0 |
Capture every span for debugging |
| Staging | 0.5 |
Moderate coverage with reasonable cost |
| Production | 0.1 |
10% sampling balances cost and visibility |
Unsampled messages still receive correlation_id in logs, so you can always
search CloudWatch Logs by correlation ID even without a matching trace.
X-Ray charges per trace recorded and per trace scanned. At 0.1 sampling with 10,000 messages/minute, you record approximately 1,000 traces/minute. Use the Total Cost of Ownership guide to estimate tracing costs for your throughput.
CloudWatch metric filters extract numeric metrics from log patterns. Use them for events that are logged but not emitted as explicit metrics.
| Filter Name | Pattern | Metric |
|---|---|---|
| Circuit breaker open | { $.msg = "circuit breaker state change" && $.to = "open" } |
CircuitBreakerOpen |
| Config reload failure | { $.msg = "*config reload rejected*" } |
ConfigReloadFailures |
| Error log count | { $.level = "ERROR" } |
ErrorLogCount |
import (
"github.com/aws/aws-cdk-go/awscdk/v2/awslogs"
"github.com/aws/jsii-runtime-go"
)
awslogs.NewMetricFilter(stack, jsii.String("CircuitBreakerOpenFilter"),
&awslogs.MetricFilterProps{
LogGroup: logGroup,
FilterName: jsii.String("CircuitBreakerOpen"),
FilterPattern: awslogs.FilterPattern_All(
awslogs.FilterPattern_StringValue(
jsii.String("$.msg"), jsii.String("="), jsii.String("circuit breaker state change"),
),
awslogs.FilterPattern_StringValue(
jsii.String("$.to"), jsii.String("="), jsii.String("open"),
),
),
MetricNamespace: jsii.String("GoBridge/Logs"),
MetricName: jsii.String("CircuitBreakerOpen"),
MetricValue: jsii.String("1"),
DefaultValue: jsii.Number(0),
},
)
awslogs.NewMetricFilter(stack, jsii.String("ErrorLogFilter"),
&awslogs.MetricFilterProps{
LogGroup: logGroup,
FilterName: jsii.String("ErrorLogCount"),
FilterPattern: awslogs.FilterPattern_StringValue(
jsii.String("$.level"), jsii.String("="), jsii.String("ERROR"),
),
MetricNamespace: jsii.String("GoBridge/Logs"),
MetricName: jsii.String("ErrorLogCount"),
MetricValue: jsii.String("1"),
DefaultValue: jsii.Number(0),
},
)
You can then create alarms on these log-derived metrics using the same approach
shown in the CloudWatch Alarms section. Place them in the GoBridge/Logs
namespace to distinguish them from runtime-emitted metrics.
If your organization uses Grafana, you can query CloudWatch, X-Ray, and CloudWatch Logs through native data source plugins.
Create a read-only IAM role that Grafana assumes:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"cloudwatch:DescribeAlarmsForMetric",
"cloudwatch:GetMetricData",
"cloudwatch:GetMetricStatistics",
"cloudwatch:ListMetrics",
"logs:GetLogEvents",
"logs:GetLogGroupFields",
"logs:GetQueryResults",
"logs:StartQuery",
"logs:StopQuery",
"xray:GetTraceSummaries",
"xray:BatchGetTraces",
"xray:GetServiceGraph"
],
"Resource": "*"
}
]
}
| Data Source | Plugin | Namespace / Settings |
|---|---|---|
| CloudWatch Metrics | cloudwatch |
Namespace: GoBridge/Runtime |
| CloudWatch Logs | cloudwatch |
Log group: /ecs/gobridge |
| X-Ray Traces | x-ray |
Region: match your deployment |
| Panel | Query |
|---|---|
| Throughput | CloudWatch: SUM(MessagesReceived), SUM(MessagesSent) over 1 min |
| Error rate | CloudWatch math: (RouteErrors / MessagesReceived) * 100 |
| Max latency | CloudWatch: MAX(DeliveryE2ELatency) over 1 min |
| Avg latency | CloudWatch: AVG(DeliveryE2ELatency) over 1 min |
| Outbox depth | CloudWatch: MAX(OutboxDepth) over 1 min |
| Log search | CloudWatch Logs Insights: filter by correlation_id or route_id |
| Trace drilldown | X-Ray: search by trace ID from log panel link |
The correlation_id field is the join key across all three data sources.
| Pillar | AWS Service | GoBridge Adapter | Config |
|---|---|---|---|
| Logging | CloudWatch Logs | observability.CorrelationHandler |
runtime.WithLogger(logger) |
| Metrics | CloudWatch Metrics | adapters/aws/metrics/cloudwatch |
runtime.WithMetrics(exporter) |
| Tracing | X-Ray via ADOT | adapters/otel/tracing |
runtime.WithTracer(tracer) |
See CDK Scenario 4 for a complete production stack that wires monitoring, alarms, and dashboards together. For cost implications of observability, see Total Cost of Ownership.