From 7d7cff084b3f5aaf52a7041a5f9b0549b20e4bf4 Mon Sep 17 00:00:00 2001 From: yzimhao Date: Sun, 2 Aug 2026 18:58:25 +0800 Subject: [PATCH 1/4] Implement broker feature enhancements --- .dockerignore | 16 + .github/workflows/integration.yml | 31 +- .github/workflows/pull-request.yml | 9 + .specify/feature.json | 3 + ADAPTER_EXTENSIONS.md | 22 + README.md | 170 ++++++ README_EN.md | 13 + broker.go | 33 + brokers/kafka/kafka.go | 40 +- brokers/nats/nats.go | 65 +- brokers/pubsub/pubsub.go | 41 +- brokers/rabbitmq/rabbitmq.go | 54 +- brokers/redis/redis.go | 50 +- brokers/rocketmq/rocketmq.go | 31 +- brokers/sqs/sqs.go | 36 +- examples/observability/README.md | 30 + examples/observability/main.go | 210 +++++++ examples/observability/main_test.go | 94 +++ integration/cloud_observability_test.go | 30 + integration/docker_test.go | 121 +++- internal/obstest/compatibility_test.go | 24 + internal/obstest/conformance.go | 83 +++ internal/obstest/conformance_test.go | 33 + middleware/otel.go | 6 + middleware/otel_test.go | 10 + noop_broker.go | 63 +- noop_broker_test.go | 21 + observability.go | 258 ++++++++ observability_benchmark_test.go | 93 +++ observability_context.go | 61 ++ observability_context_test.go | 49 ++ observability_event_test.go | 71 +++ observability_failure_test.go | 51 ++ observability_health_test.go | 57 ++ observability_metrics_test.go | 42 ++ observability_redact.go | 81 +++ observability_redact_test.go | 57 ++ observability_runtime.go | 576 ++++++++++++++++++ observability_test.go | 334 ++++++++++ options.go | 18 + .../006-observability-diagnostics/baseline.md | 21 + .../checklists/requirements.md | 35 ++ .../contracts/example.md | 71 +++ .../contracts/public-api.md | 99 +++ .../contracts/signal-catalog.md | 65 ++ .../data-model.md | 131 ++++ specs/006-observability-diagnostics/plan.md | 176 ++++++ .../quickstart.md | 151 +++++ .../006-observability-diagnostics/research.md | 165 +++++ specs/006-observability-diagnostics/spec.md | 284 +++++++++ specs/006-observability-diagnostics/tasks.md | 306 ++++++++++ .../validation.md | 35 ++ 52 files changed, 4560 insertions(+), 66 deletions(-) create mode 100644 .dockerignore create mode 100644 .specify/feature.json create mode 100644 examples/observability/README.md create mode 100644 examples/observability/main.go create mode 100644 examples/observability/main_test.go create mode 100644 integration/cloud_observability_test.go create mode 100644 internal/obstest/compatibility_test.go create mode 100644 internal/obstest/conformance.go create mode 100644 internal/obstest/conformance_test.go create mode 100644 observability.go create mode 100644 observability_benchmark_test.go create mode 100644 observability_context.go create mode 100644 observability_context_test.go create mode 100644 observability_event_test.go create mode 100644 observability_failure_test.go create mode 100644 observability_health_test.go create mode 100644 observability_metrics_test.go create mode 100644 observability_redact.go create mode 100644 observability_redact_test.go create mode 100644 observability_runtime.go create mode 100644 observability_test.go create mode 100644 specs/006-observability-diagnostics/baseline.md create mode 100644 specs/006-observability-diagnostics/checklists/requirements.md create mode 100644 specs/006-observability-diagnostics/contracts/example.md create mode 100644 specs/006-observability-diagnostics/contracts/public-api.md create mode 100644 specs/006-observability-diagnostics/contracts/signal-catalog.md create mode 100644 specs/006-observability-diagnostics/data-model.md create mode 100644 specs/006-observability-diagnostics/plan.md create mode 100644 specs/006-observability-diagnostics/quickstart.md create mode 100644 specs/006-observability-diagnostics/research.md create mode 100644 specs/006-observability-diagnostics/spec.md create mode 100644 specs/006-observability-diagnostics/tasks.md create mode 100644 specs/006-observability-diagnostics/validation.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d4c67a6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git/ +.github/ +.dockerignore +Dockerfile* +vendor/ +bin/ +coverage/ +*.out +*.test +*.prof +*.log* +.env* +.DS_Store +.idea/ +.vscode/ +specs/ diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 78293f1..fa22e69 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -28,11 +28,38 @@ jobs: run: make integration-up - name: Run Docker integration tests - run: go test -tags=integration -count=1 -v ./integration + shell: bash + run: | + set -o pipefail + go test -tags=integration -count=1 -v ./integration 2>&1 | tee integration-test.log + + - name: Reject sensitive observability output + if: always() + shell: bash + run: | + sed -E 's/((authorization|password|secret|token|api[-_]?key)[=:])[[:graph:]]+/\1[REDACTED]/Ig' integration-test.log > sanitized-integration-test.log + if grep -Eiq '(authorization|password|secret|token|api[-_]?key)[=:][^[:space:]]+' integration-test.log; then + echo 'Sensitive-looking value found in integration output' + exit 1 + fi - name: Show container logs on failure if: failure() - run: docker compose -f docker-compose.integration.yml logs --no-color + shell: bash + run: | + docker compose -f docker-compose.integration.yml logs --no-color 2>&1 \ + | sed -E 's/((authorization|password|secret|token|api[-_]?key)[=:])[[:graph:]]+/\1[REDACTED]/Ig' \ + | tee sanitized-container.log + + - name: Upload sanitized failure logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: sanitized-integration-logs + path: | + sanitized-integration-test.log + sanitized-container.log + if-no-files-found: ignore - name: Clean up containers if: always() diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 6c921d4..e400c26 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -41,6 +41,15 @@ jobs: - name: Run go vet run: go vet ./... + - name: Build observability example + run: go build ./examples/observability + + - name: Smoke test observability switches + run: | + go run ./examples/observability + go run ./examples/observability -observe -health -diagnostics + go run ./examples/observability -observe -metrics -correlation -events + - name: Run golangci-lint uses: golangci/golangci-lint-action@v6 with: diff --git a/.specify/feature.json b/.specify/feature.json new file mode 100644 index 0000000..759b339 --- /dev/null +++ b/.specify/feature.json @@ -0,0 +1,3 @@ +{ + "feature_directory": "specs/006-observability-diagnostics" +} diff --git a/ADAPTER_EXTENSIONS.md b/ADAPTER_EXTENSIONS.md index 925227b..06cc739 100644 --- a/ADAPTER_EXTENSIONS.md +++ b/ADAPTER_EXTENSIONS.md @@ -175,3 +175,25 @@ | `redis.WithMaxLen(int64)` | 设置 Stream 的最大长度 (`MAXLEN`) | **注**:Redis 适配器基于 **Redis Streams** 实现。订阅时必须通过 `broker.Queue(groupName)` 指定 Consumer Group 名称以保证消息可靠消费(PEL 支持)。 + +--- + +## 10. 可观测能力矩阵 + +所有内置适配器都通过可选 `broker.Observable` 接口提供本地生命周期状态、不可变诊断快照、发布/Handler/Ack/Nack 结果、固定维度指标和 W3C 上下文传递。该能力不会加入 `broker.Broker`,第三方实现无需修改。 + +| 适配器 | 被动健康 | 主动探测 | 诊断/指标 | 上下文传递 | 验证方式 | +| :--- | :--- | :--- | :--- | :--- | :--- | +| Kafka | 支持 | 可取消 TCP dial | 支持 | Header | Docker 集成 | +| RabbitMQ | 支持 | 连接状态检查 | 支持 | AMQP Header | Docker 集成 | +| NATS | 支持 | `FlushWithContext` | 支持 | Header | Docker 集成 | +| Redis Streams | 支持 | `PING` | 支持 | Stream 字段 | Docker 集成 | +| RocketMQ | 支持 | 暂不支持 | 支持 | Message Property | Mock;发布前需真实服务复验 | +| AWS SQS | 支持 | 暂不支持 | 支持 | Message Attribute | Mock;发布前需真实 AWS 队列复验 | +| GCP Pub/Sub | 支持 | 暂不支持 | 支持 | Message Attribute | Mock;发布前需真实 GCP 项目复验 | + +被动 `Health()` 只读取本地同步状态,不发起网络请求。`Probe(ctx)` 仅在表中明确支持时执行,并遵守调用方取消和超时;其他适配器返回可用 `errors.Is` 判断的 `broker.ErrUnsupported`。所有能力默认关闭,启用方式和故障场景见 `examples/observability/`。 + +### 云适配器发布前验证 + +`go test -count=1 ./integration -run CloudAdapterObservabilityContracts` 会在无云凭证时验证 RocketMQ、SQS 与 Pub/Sub 的被动能力、固定信号集合、上下文复制和“不支持主动探测”契约。发布版本还应分别在隔离的真实 RocketMQ NameServer、AWS 测试队列和 GCP 测试项目中运行各适配器包测试,并人工确认:连接状态可恢复、消息正文和凭证未出现在输出中、Trace Context 能随消息往返、关闭后无后台消费者残留。真实服务凭证仅通过环境或工作负载身份注入,不写入命令、日志或仓库。 diff --git a/README.md b/README.md index a545032..6bca0a3 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,176 @@ QvCloud Broker 是一个面向生产场景的 Go 语言消息中间件抽象层 | **GCP Pub/Sub** | 🧪 持续完善 | **62.0%** | 谷歌云发布订阅 | - **可扩展性**: 插件化架构,轻松接入新的 MQ 实现。 - **统一模型**: 厂商无关的消息模型。 +- **按需可观测**: 日志、健康、诊断快照、指标、上下文关联和状态事件默认关闭并可独立开启;现有 `Broker` 接口保持兼容。 + +## 可观测性与故障排查 + +可观测能力是可选功能,默认全部关闭,不会改变现有 `broker.Broker` 接口。应用可以按需独立启用健康状态、诊断快照、结构化故障、指标、Trace Context 和状态事件。 + +### 快速体验与效果 + +示例使用内存 No-op Broker,无需 Docker、MQ 服务或账号: + +```bash +# 默认关闭:只有业务执行结果,没有可观测输出 +go run ./examples/observability + +# 查询被动健康状态和诊断快照 +go run ./examples/observability -observe -health -diagnostics + +# 开启全部信号并模拟发布失败 +go run ./examples/observability -observe -all -scenario=publish-failure + +# 主动探测;-probe 必须与 -health 一起使用 +go run ./examples/observability -observe -health -probe +``` + +正常场景的典型输出: + +```text +health state=unknown ready=false +snapshot broker=noop state=ready subscriptions=1 dropped=0 error_category= +scenario name=normal status=complete +``` + +发布失败时的典型输出: + +```text +event kind=operation.failed operation=publish outcome=failure state=ready +snapshot broker=noop state=ready subscriptions=1 dropped=0 error_category=publish +scenario error: demonstration publish failure +``` + +这些输出可以直接回答“是否已连接”“是否可接收流量”“哪个操作失败”“当前订阅数是多少”“事件是否因缓冲区满而丢弃”等排障问题。输出不会包含消息正文、连接凭据、签名查询参数或原始 Trace ID。 + +### 在实际 Broker 中启用 + +下面以 Kafka 为例;同一个 `broker.WithObservability(...)` 可用于 RabbitMQ、NATS、Redis、RocketMQ、SQS、Pub/Sub 和 No-op Broker: + +```go +package main + +import ( + "context" + "fmt" + "time" + + "github.com/qvcloud/broker" + "github.com/qvcloud/broker/brokers/kafka" +) + +func main() { + sink := broker.EventSinkFunc(func(_ context.Context, event broker.BrokerStateEvent) error { + // 可替换为 slog、日志平台或告警系统;不要在这里执行耗时操作。 + fmt.Printf("kind=%s operation=%s outcome=%s state=%s\n", + event.Kind, event.Operation, event.Outcome, event.To) + return nil + }) + + b := kafka.NewBroker( + broker.Addrs("127.0.0.1:9092"), + broker.WithObservability( + broker.EnableCategories( + broker.CategoryHealth, + broker.CategoryDiagnostics, + broker.CategoryLogging, + broker.CategoryMeasurements, + broker.CategoryCorrelation, + broker.CategoryStateEvents, + ), + broker.WithEventSink(sink), + broker.WithEventBuffer(256, broker.OverflowDropNewest), + broker.WithSinkTimeout(time.Second), + broker.WithProbeTimeout(3*time.Second), + broker.WithRedactedFields("x-company-secret"), + broker.WithInstanceID("order-consumer-01"), + ), + ) + + if err := b.Connect(); err != nil { + panic(err) + } + defer b.Disconnect() + + observable, ok := b.(broker.Observable) + if !ok { + // 第三方 Broker 可以不实现该可选接口。 + return + } + + health := observable.Observability().Health() + fmt.Printf("state=%s connected=%t ready=%t\n", + health.State, health.Connected, health.Ready) + + snapshot := observable.Observability().Snapshot() + fmt.Printf("subscriptions=%d publish_success=%d last_failure=%+v\n", + snapshot.SubscriptionCount, + snapshot.OperationTotals["publish:success"], + snapshot.LastFailure) +} +``` + +`CategoryLogging` 或 `CategoryStateEvents` 开启时必须配置 `WithEventSink`,否则 `Connect()` 返回 `broker.ErrInvalidObservabilityConfig`。事件通过有界单 worker 异步派发;Sink 阻塞、报错或 panic 不会改变 Publish、Handler、Ack、Nack 的业务结果。队列满时按照指定策略丢弃,并通过 `Snapshot().DroppedRecords` 暴露数量。 + +### 分类开关 + +| 分类 | 作用 | 主要效果 | +| :--- | :--- | :--- | +| `CategoryLogging` | 结构化故障记录 | 向 Event Sink 发送已脱敏的操作失败事件 | +| `CategoryHealth` | 健康状态 | 查询 `unknown/connecting/ready/degraded/reconnecting/stopped` | +| `CategoryDiagnostics` | 诊断快照 | 查看操作结果、订阅数、进行中操作、最后错误和丢弃数 | +| `CategoryMeasurements` | OpenTelemetry 指标 | 记录操作次数、耗时、进行中数量、重试、重连和订阅数 | +| `CategoryCorrelation` | 调用链关联 | 使用 W3C `traceparent`/`tracestate` 随消息 Header 传播上下文 | +| `CategoryStateEvents` | 状态事件 | 异步发送连接状态变化和故障事件 | + +运行期间也可以安全调整分类: + +```go +obs := b.(broker.Observable).Observability() + +// 临时只保留健康和诊断能力 +err := obs.SetCategories(broker.Categories( + broker.CategoryHealth, + broker.CategoryDiagnostics, +)) +``` + +### 健康检查与主动探测 + +`Health()` 是纯本地、无网络 I/O 的被动查询,适合高频状态页和排障接口。`Probe(ctx)` 才会执行主动检查,并受调用方取消和 `WithProbeTimeout` 限制: + +```go +ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) +defer cancel() + +status, err := b.(broker.Observable).Observability().Probe(ctx) +switch { +case err == nil && status.Ready: + // 主动探测成功 +case errors.Is(err, broker.ErrUnsupported): + // 当前适配器不支持主动探测,继续使用 Health() 的被动状态 +default: + // 超时、取消或连接异常 +} +``` + +| 适配器 | 主动探测方式 | +| :--- | :--- | +| Kafka | 可取消 TCP Dial | +| RabbitMQ | AMQP 连接状态检查 | +| NATS | `FlushWithContext` | +| Redis | `PING` | +| RocketMQ、SQS、GCP Pub/Sub | 返回 `broker.ErrUnsupported` | + +### 推荐排障顺序 + +1. 调用 `Health()` 判断当前是未连接、降级、重连还是已停止。 +2. 必要时调用有超时的 `Probe(ctx)`,区分本地状态异常和网络端点异常。 +3. 读取 `Snapshot().LastFailure` 的安全错误分类,以及 `OperationTotals` 中对应操作的成功/失败次数。 +4. 检查 `SubscriptionCount`、`InFlight` 和 `DroppedRecords`,判断消费者、积压操作或事件 Sink 是否异常。 +5. 使用 Trace Context 在生产者与 Handler 链路间关联问题,但不要把消息 ID、Topic、用户 ID 等高基数字段作为指标维度。 + +完整示例开关和场景说明见 [`examples/observability`](examples/observability/README.md),各适配器差异见 [`ADAPTER_EXTENSIONS.md`](ADAPTER_EXTENSIONS.md)。禁用模式不启动事件 worker,实测目标热路径开销低于 1%;标准启用组合的代表性吞吐开销门禁为低于 5%。 ## 项目结构 diff --git a/README_EN.md b/README_EN.md index d75fddd..b004c5c 100644 --- a/README_EN.md +++ b/README_EN.md @@ -24,6 +24,19 @@ QvCloud Broker is a production-oriented messaging abstraction for Go. It provide | **GCP Pub/Sub** | 🧪 Improving | **62.0%** | Google Cloud Pub/Sub | - **Extensibility**: Plugin-based architecture for easy integration of new MQ implementations. - **Universal Model**: A vendor-agnostic message structure. +- **Opt-in Observability**: Logging, health, snapshots, measurements, correlation, and state events are off by default and independently selectable without changing `Broker`. + +### Observability and troubleshooting example + +```bash +go run ./examples/observability +go run ./examples/observability -observe -health -diagnostics +go run ./examples/observability -observe -all -scenario=handler-failure +``` + +Passive health performs no network call; active probing is explicit through `-probe`. Diagnostic output excludes message bodies, connection credentials, and raw correlation values. See [`examples/observability`](examples/observability/README.md) for every switch and failure scenario. + +Disabled mode starts no background worker and targets less than 1% hot-path overhead; the standard logging, diagnostics, measurements, and correlation package targets less than 5%, verified with same-machine benchmarks. Unsupported adapter probes return `broker.ErrUnsupported` explicitly rather than reporting fabricated health. ## Project Structure diff --git a/broker.go b/broker.go index 363cab5..c14cb5f 100644 --- a/broker.go +++ b/broker.go @@ -2,9 +2,17 @@ package broker import ( "context" + "errors" "sync" ) +var ( + // ErrUnsupported indicates that a broker cannot provide an optional capability. + ErrUnsupported = errors.New("broker: observability capability unsupported") + // ErrInvalidObservabilityConfig indicates invalid observability configuration. + ErrInvalidObservabilityConfig = errors.New("broker: invalid observability configuration") +) + // Broker is an interface used for asynchronous messaging. // It provides a unified API to interact with different message brokers. type Broker interface { @@ -28,6 +36,31 @@ type Broker interface { String() string } +// Observable is an optional capability implemented by brokers that expose +// health and diagnostic information. Broker intentionally does not embed it, +// preserving compatibility with third-party implementations. +type Observable interface { + Observability() Observability +} + +// Observability is the concurrency-safe operational view of one broker. +type Observability interface { + HealthChecker + DiagnosticsProvider + SetCategories(CategorySet) error +} + +// HealthChecker exposes passive local health and an explicit active probe. +type HealthChecker interface { + Health() HealthStatus + Probe(context.Context) (HealthStatus, error) +} + +// DiagnosticsProvider returns an immutable point-in-time diagnostic snapshot. +type DiagnosticsProvider interface { + Snapshot() DiagnosticSnapshot +} + // Handler is used to process messages via a subscription of a topic. type Handler func(context.Context, Event) error diff --git a/brokers/kafka/kafka.go b/brokers/kafka/kafka.go index 83ad923..c1f730c 100644 --- a/brokers/kafka/kafka.go +++ b/brokers/kafka/kafka.go @@ -22,6 +22,7 @@ type kafkaReader interface { } type kafkaBroker struct { + *broker.Runtime opts broker.Options writer kafkaWriter @@ -53,7 +54,17 @@ func (k *kafkaBroker) Init(opts ...broker.Option) error { return nil } -func (k *kafkaBroker) Connect() error { +func (k *kafkaBroker) Connect() (err error) { + defer func() { + if err != nil { + k.Transition(broker.StateDegraded, false, false, err) + } + }() + if err := k.ValidationError(); err != nil { + return err + } + k.Start() + k.Transition(broker.StateConnecting, false, false, nil) k.Lock() defer k.Unlock() @@ -110,9 +121,17 @@ func (k *kafkaBroker) Connect() error { BatchSize: batchSize, AllowAutoTopicCreation: autoCreateTopic, }) + k.SetProbe(func(ctx context.Context) error { + conn, err := dialer.DialContext(ctx, "tcp", k.opts.Addrs[0]) + if err != nil { + return err + } + return conn.Close() + }) k.ctx, k.cancel = context.WithCancel(context.Background()) k.running = true + k.Transition(broker.StateReady, true, true, nil) broker.WarnUnconsumed(k.opts.Context, k.opts.Logger) return nil @@ -141,10 +160,16 @@ func (k *kafkaBroker) Disconnect() error { k.readers = make(map[string]kafkaReader) k.running = false + k.Transition(broker.StateStopped, false, false, nil) + k.Runtime.Close() return nil } -func (k *kafkaBroker) Publish(ctx context.Context, topic string, msg *broker.Message, opts ...broker.PublishOption) error { +func (k *kafkaBroker) Publish(ctx context.Context, topic string, msg *broker.Message, opts ...broker.PublishOption) (err error) { + if k.Runtime != nil && k.OperationsEnabled() { + started := k.StartOperation(broker.OperationPublish) + defer func() { k.Observe(broker.OperationPublish, started, err) }() + } options := broker.PublishOptions{ Context: ctx, } @@ -153,7 +178,7 @@ func (k *kafkaBroker) Publish(ctx context.Context, topic string, msg *broker.Mes } headers := []kafka.Header{} - for key, val := range msg.Header { + for key, val := range k.InjectContext(ctx, msg.Header) { headers = append(headers, kafka.Header{ Key: key, Value: []byte(val), @@ -167,7 +192,7 @@ func (k *kafkaBroker) Publish(ctx context.Context, topic string, msg *broker.Mes } } - err := k.writer.WriteMessages(ctx, kafka.Message{ + err = k.writer.WriteMessages(ctx, kafka.Message{ Topic: topic, Partition: partition, Key: []byte(options.ShardingKey), @@ -180,6 +205,7 @@ func (k *kafkaBroker) Publish(ctx context.Context, topic string, msg *broker.Mes } func (k *kafkaBroker) Subscribe(topic string, handler broker.Handler, opts ...broker.SubscribeOption) (broker.Subscriber, error) { + handler = k.WrapHandler(handler) options := broker.NewSubscribeOptions(opts...) if len(k.opts.Addrs) == 0 { @@ -246,6 +272,7 @@ func (k *kafkaBroker) Subscribe(topic string, handler broker.Handler, opts ...br if ctx.Err() != nil { return } + k.Observe(broker.OperationReceive, time.Now(), err) if k.opts.Logger != nil { k.opts.Logger.Logf("Kafka fetch error: %v", err) } @@ -278,12 +305,12 @@ func (k *kafkaBroker) Subscribe(topic string, handler broker.Handler, opts ...br } }() - return &kafkaSubscriber{ + return k.WrapSubscriber(&kafkaSubscriber{ topic: topic, opts: options, reader: reader, cancel: cancel, - }, nil + }), nil } func (k *kafkaBroker) String() string { @@ -347,6 +374,7 @@ func NewBroker(opts ...broker.Option) broker.Broker { options := broker.NewOptions(opts...) return &kafkaBroker{ + Runtime: broker.NewRuntimeForOptions("kafka", *options), opts: *options, readers: make(map[string]kafkaReader), newWriter: func(w *kafka.Writer) kafkaWriter { diff --git a/brokers/nats/nats.go b/brokers/nats/nats.go index 68f8636..e48c91d 100644 --- a/brokers/nats/nats.go +++ b/brokers/nats/nats.go @@ -18,6 +18,7 @@ type natsConn interface { } type natsBroker struct { + *broker.Runtime opts broker.Options conn natsConn @@ -45,7 +46,17 @@ func (n *natsBroker) Init(opts ...broker.Option) error { return nil } -func (n *natsBroker) Connect() error { +func (n *natsBroker) Connect() (err error) { + defer func() { + if err != nil { + n.Transition(broker.StateDegraded, false, false, err) + } + }() + if err := n.ValidationError(); err != nil { + return err + } + n.Start() + n.Transition(broker.StateConnecting, false, false, nil) n.Lock() defer n.Unlock() @@ -58,12 +69,21 @@ func (n *natsBroker) Connect() error { } addr := n.Address() - var ( - conn natsConn - err error - ) + var conn natsConn opts := []nats.Option{} + opts = append(opts, + nats.DisconnectErrHandler(func(_ *nats.Conn, err error) { + n.Transition(broker.StateReconnecting, false, false, err) + }), + nats.ReconnectHandler(func(_ *nats.Conn) { + n.Observe(broker.OperationReconnect, time.Now(), nil) + n.Transition(broker.StateReady, true, true, nil) + }), + nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { + n.Observe(broker.OperationReceive, time.Now(), err) + }), + ) if n.opts.TLSConfig != nil { opts = append(opts, nats.Secure(n.opts.TLSConfig)) } @@ -88,9 +108,24 @@ func (n *natsBroker) Connect() error { return err } n.conn = conn + n.SetProbe(func(ctx context.Context) error { + n.RLock() + current := n.conn + running := n.running + n.RUnlock() + if !running || current == nil { + return broker.ErrUnsupported + } + flusher, ok := current.(interface{ FlushWithContext(context.Context) error }) + if !ok { + return broker.ErrUnsupported + } + return flusher.FlushWithContext(ctx) + }) n.ctx, n.cancel = context.WithCancel(context.Background()) n.running = true + n.Transition(broker.StateReady, true, true, nil) // Warn about unconsumed options broker.WarnUnconsumed(n.opts.Context, n.opts.Logger) @@ -115,10 +150,16 @@ func (n *natsBroker) Disconnect() error { } n.running = false + n.Transition(broker.StateStopped, false, false, nil) + n.Runtime.Close() return nil } -func (n *natsBroker) Publish(ctx context.Context, topic string, msg *broker.Message, opts ...broker.PublishOption) error { +func (n *natsBroker) Publish(ctx context.Context, topic string, msg *broker.Message, opts ...broker.PublishOption) (err error) { + if n.Runtime != nil && n.OperationsEnabled() { + started := n.StartOperation(broker.OperationPublish) + defer func() { n.Observe(broker.OperationPublish, started, err) }() + } options := broker.PublishOptions{ Context: ctx, } @@ -146,11 +187,11 @@ func (n *natsBroker) Publish(ctx context.Context, topic string, msg *broker.Mess } } - for k, v := range msg.Header { + for k, v := range n.InjectContext(ctx, msg.Header) { nm.Header.Set(k, v) } - err := conn.PublishMsg(nm) + err = conn.PublishMsg(nm) if err == nil { broker.WarnUnconsumed(options.Context, n.opts.Logger) } @@ -158,6 +199,7 @@ func (n *natsBroker) Publish(ctx context.Context, topic string, msg *broker.Mess } func (n *natsBroker) Subscribe(topic string, handler broker.Handler, opts ...broker.SubscribeOption) (broker.Subscriber, error) { + handler = n.WrapHandler(handler) options := broker.NewSubscribeOptions(opts...) n.Lock() @@ -213,12 +255,12 @@ func (n *natsBroker) Subscribe(topic string, handler broker.Handler, opts ...bro return nil, err } - return &natsSubscriber{ + return n.WrapSubscriber(&natsSubscriber{ topic: topic, opts: options, sub: sub, cancel: cancel, - }, nil + }), nil } func (n *natsBroker) String() string { @@ -264,7 +306,8 @@ func (e *natsEvent) Error() error { return nil } func NewBroker(opts ...broker.Option) broker.Broker { options := broker.NewOptions(opts...) return &natsBroker{ - opts: *options, + Runtime: broker.NewRuntimeForOptions("nats", *options), + opts: *options, newConn: func(addr string, opts ...nats.Option) (natsConn, error) { return nats.Connect(addr, opts...) }, diff --git a/brokers/pubsub/pubsub.go b/brokers/pubsub/pubsub.go index 3cbb100..395324f 100644 --- a/brokers/pubsub/pubsub.go +++ b/brokers/pubsub/pubsub.go @@ -45,6 +45,7 @@ func (r *realPubSubProvider) Close() error { } type pubsubBroker struct { + *broker.Runtime opts broker.Options provider pubsubProvider newProvider func(context.Context, string) (pubsubProvider, error) @@ -71,7 +72,17 @@ func (p *pubsubBroker) Init(opts ...broker.Option) error { return nil } -func (p *pubsubBroker) Connect() error { +func (p *pubsubBroker) Connect() (err error) { + defer func() { + if err != nil { + p.Transition(broker.StateDegraded, false, false, err) + } + }() + if err := p.ValidationError(); err != nil { + return err + } + p.Start() + p.Transition(broker.StateConnecting, false, false, nil) p.Lock() defer p.Unlock() @@ -96,6 +107,7 @@ func (p *pubsubBroker) Connect() error { p.provider = client p.ctx, p.cancel = context.WithCancel(context.Background()) p.running = true + p.Transition(broker.StateReady, true, true, nil) // Warn about unconsumed options broker.WarnUnconsumed(p.opts.Context, p.opts.Logger) @@ -121,10 +133,16 @@ func (p *pubsubBroker) Disconnect() error { } p.provider = nil p.running = false + p.Transition(broker.StateStopped, false, false, nil) + p.Runtime.Close() return closeErr } -func (p *pubsubBroker) Publish(ctx context.Context, topic string, msg *broker.Message, opts ...broker.PublishOption) error { +func (p *pubsubBroker) Publish(ctx context.Context, topic string, msg *broker.Message, opts ...broker.PublishOption) (err error) { + if p.Runtime != nil && p.OperationsEnabled() { + started := p.StartOperation(broker.OperationPublish) + defer func() { p.Observe(broker.OperationPublish, started, err) }() + } options := broker.PublishOptions{ Context: ctx, } @@ -141,7 +159,7 @@ func (p *pubsubBroker) Publish(ctx context.Context, topic string, msg *broker.Me } attributes := make(map[string]string) - for k, v := range msg.Header { + for k, v := range p.InjectContext(ctx, msg.Header) { attributes[k] = v } @@ -150,7 +168,7 @@ func (p *pubsubBroker) Publish(ctx context.Context, topic string, msg *broker.Me Attributes: attributes, }) - _, err := res.Get(ctx) + _, err = res.Get(ctx) if err == nil { broker.WarnUnconsumed(options.Context, p.opts.Logger) } @@ -158,6 +176,7 @@ func (p *pubsubBroker) Publish(ctx context.Context, topic string, msg *broker.Me } func (p *pubsubBroker) Subscribe(topic string, handler broker.Handler, opts ...broker.SubscribeOption) (broker.Subscriber, error) { + handler = p.WrapHandler(handler) options := broker.NewSubscribeOptions(opts...) p.RLock() @@ -201,16 +220,19 @@ func (p *pubsubBroker) Subscribe(topic string, handler broker.Handler, opts ...b event.Ack() } }) - if err != nil && p.opts.Logger != nil { - p.opts.Logger.Logf("Pub/Sub receive error: %v", err) + if err != nil { + p.Observe(broker.OperationReceive, time.Now(), err) + if p.opts.Logger != nil { + p.opts.Logger.Logf("Pub/Sub receive error: %v", err) + } } }() - return &pubsubSubscriber{ + return p.WrapSubscriber(&pubsubSubscriber{ topic: topic, opts: options, cancel: cancel, - }, nil + }), nil } func (p *pubsubBroker) String() string { @@ -255,7 +277,8 @@ func (e *pubsubEvent) Error() error { return nil } func NewBroker(opts ...broker.Option) broker.Broker { options := broker.NewOptions(opts...) return &pubsubBroker{ - opts: *options, + Runtime: broker.NewRuntimeForOptions("pubsub", *options), + opts: *options, newProvider: func(ctx context.Context, projectID string) (pubsubProvider, error) { client, err := pubsub.NewClient(ctx, projectID) if err != nil { diff --git a/brokers/rabbitmq/rabbitmq.go b/brokers/rabbitmq/rabbitmq.go index da32c69..e09772d 100644 --- a/brokers/rabbitmq/rabbitmq.go +++ b/brokers/rabbitmq/rabbitmq.go @@ -33,6 +33,7 @@ func (w *connWrapper) Channel() (rabbitChannel, error) { } type rmqBroker struct { + *broker.Runtime opts broker.Options conn rabbitConn @@ -65,7 +66,17 @@ func (r *rmqBroker) Init(opts ...broker.Option) error { return nil } -func (r *rmqBroker) Connect() error { +func (r *rmqBroker) Connect() (err error) { + defer func() { + if err != nil { + r.Transition(broker.StateDegraded, false, false, err) + } + }() + if err := r.ValidationError(); err != nil { + return err + } + r.Start() + r.Transition(broker.StateConnecting, false, false, nil) r.Lock() defer r.Unlock() @@ -92,6 +103,21 @@ func (r *rmqBroker) Connect() error { return err } r.conn = conn + r.SetProbe(func(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + r.RLock() + connection := r.conn + running := r.running + r.RUnlock() + if !running || connection == nil || connection.IsClosed() { + return fmt.Errorf("rabbitmq: connection is not ready") + } + return nil + }) ch, err := conn.Channel() if err != nil { @@ -102,6 +128,7 @@ func (r *rmqBroker) Connect() error { r.ctx, r.cancel = context.WithCancel(context.Background()) r.running = true + r.Transition(broker.StateReady, true, true, nil) // Warn about unconsumed options at connection time broker.WarnUnconsumed(r.opts.Context, r.opts.Logger) @@ -119,6 +146,8 @@ func (r *rmqBroker) Connect() error { r.RUnlock() if conn == nil || conn.IsClosed() { + r.Transition(broker.StateReconnecting, false, false, nil) + started := r.StartOperation(broker.OperationReconnect) if r.opts.Logger != nil { r.opts.Logger.Log("RabbitMQ connection lost, reconnecting...") } @@ -130,7 +159,10 @@ func (r *rmqBroker) Connect() error { r.channel = ch } r.Unlock() + r.Observe(broker.OperationReconnect, started, nil) + r.Transition(broker.StateReady, true, true, nil) } else { + r.Observe(broker.OperationReconnect, started, err) if r.opts.Logger != nil { r.opts.Logger.Logf("RabbitMQ reconnection failed: %v", err) } @@ -168,10 +200,16 @@ func (r *rmqBroker) Disconnect() error { } r.running = false + r.Transition(broker.StateStopped, false, false, nil) + r.Runtime.Close() return nil } -func (r *rmqBroker) Publish(ctx context.Context, topic string, msg *broker.Message, opts ...broker.PublishOption) error { +func (r *rmqBroker) Publish(ctx context.Context, topic string, msg *broker.Message, opts ...broker.PublishOption) (err error) { + if r.Runtime != nil && r.OperationsEnabled() { + started := r.StartOperation(broker.OperationPublish) + defer func() { r.Observe(broker.OperationPublish, started, err) }() + } options := broker.PublishOptions{ Context: ctx, } @@ -187,7 +225,7 @@ func (r *rmqBroker) Publish(ctx context.Context, topic string, msg *broker.Messa return fmt.Errorf("not connected") } - headers := stringMapToTable(msg.Header) + headers := stringMapToTable(r.InjectContext(ctx, msg.Header)) if options.Delay > 0 { headers["x-delay"] = int64(options.Delay.Milliseconds()) } @@ -215,7 +253,7 @@ func (r *rmqBroker) Publish(ctx context.Context, topic string, msg *broker.Messa } } - err := ch.PublishWithContext(ctx, + err = ch.PublishWithContext(ctx, exchange, // exchange topic, // routing key mandatory, // mandatory @@ -236,6 +274,7 @@ func (r *rmqBroker) Publish(ctx context.Context, topic string, msg *broker.Messa } func (r *rmqBroker) Subscribe(topic string, handler broker.Handler, opts ...broker.SubscribeOption) (broker.Subscriber, error) { + handler = r.WrapHandler(handler) options := broker.NewSubscribeOptions(opts...) r.RLock() @@ -250,11 +289,11 @@ func (r *rmqBroker) Subscribe(topic string, handler broker.Handler, opts ...brok go r.runSubscriber(ctx, topic, handler, options) - return &rmqSubscriber{ + return r.WrapSubscriber(&rmqSubscriber{ topic: topic, opts: options, cancel: cancel, - }, nil + }), nil } func (r *rmqBroker) runSubscriber(ctx context.Context, topic string, handler broker.Handler, options broker.SubscribeOptions) { @@ -428,7 +467,8 @@ func (e *rmqEvent) Error() error { return nil } func NewBroker(opts ...broker.Option) broker.Broker { options := broker.NewOptions(opts...) return &rmqBroker{ - opts: *options, + Runtime: broker.NewRuntimeForOptions("rabbitmq", *options), + opts: *options, newConn: func(addr string, config amqp.Config) (rabbitConn, error) { conn, err := amqp.DialConfig(addr, config) if err != nil { diff --git a/brokers/redis/redis.go b/brokers/redis/redis.go index 61d353c..2d55ac5 100644 --- a/brokers/redis/redis.go +++ b/brokers/redis/redis.go @@ -21,6 +21,7 @@ type redisClient interface { } type redisBroker struct { + *broker.Runtime opts broker.Options client redisClient @@ -48,7 +49,17 @@ func (r *redisBroker) Init(opts ...broker.Option) error { return nil } -func (r *redisBroker) Connect() error { +func (r *redisBroker) Connect() (err error) { + defer func() { + if err != nil { + r.Transition(broker.StateDegraded, false, false, err) + } + }() + if err := r.ValidationError(); err != nil { + return err + } + r.Start() + r.Transition(broker.StateConnecting, false, false, nil) r.Lock() defer r.Unlock() @@ -79,16 +90,28 @@ func (r *redisBroker) Connect() error { } r.client = r.newClient(redisOpts) + r.SetProbe(func(ctx context.Context) error { + r.RLock() + client := r.client + running := r.running + r.RUnlock() + if !running || client == nil { + return broker.ErrUnsupported + } + return client.Ping(ctx).Err() + }) // Check connection ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := r.client.Ping(ctx).Err(); err != nil { + r.Transition(broker.StateDegraded, false, false, err) return fmt.Errorf("redis: connect error: %v", err) } r.ctx, r.cancel = context.WithCancel(context.Background()) r.running = true + r.Transition(broker.StateReady, true, true, nil) broker.WarnUnconsumed(r.opts.Context, r.opts.Logger) return nil @@ -111,10 +134,16 @@ func (r *redisBroker) Disconnect() error { } r.running = false + r.Transition(broker.StateStopped, false, false, nil) + r.Runtime.Close() return nil } -func (r *redisBroker) Publish(ctx context.Context, topic string, msg *broker.Message, opts ...broker.PublishOption) error { +func (r *redisBroker) Publish(ctx context.Context, topic string, msg *broker.Message, opts ...broker.PublishOption) (err error) { + if r.Runtime != nil && r.OperationsEnabled() { + started := r.StartOperation(broker.OperationPublish) + defer func() { r.Observe(broker.OperationPublish, started, err) }() + } options := broker.PublishOptions{Context: ctx} for _, o := range opts { o(&options) @@ -130,7 +159,7 @@ func (r *redisBroker) Publish(ctx context.Context, topic string, msg *broker.Mes values := make(map[string]interface{}) values["body"] = msg.Body - for k, v := range msg.Header { + for k, v := range r.InjectContext(ctx, msg.Header) { values["h:"+k] = v } @@ -148,7 +177,7 @@ func (r *redisBroker) Publish(ctx context.Context, topic string, msg *broker.Mes Approx: true, } - err := client.XAdd(ctx, arg).Err() + err = client.XAdd(ctx, arg).Err() if err == nil { broker.WarnUnconsumed(options.Context, r.opts.Logger) } @@ -156,6 +185,7 @@ func (r *redisBroker) Publish(ctx context.Context, topic string, msg *broker.Mes } func (r *redisBroker) Subscribe(topic string, handler broker.Handler, opts ...broker.SubscribeOption) (broker.Subscriber, error) { + handler = r.WrapHandler(handler) options := broker.NewSubscribeOptions(opts...) group := options.Queue @@ -205,10 +235,10 @@ func (r *redisBroker) Subscribe(topic string, handler broker.Handler, opts ...br } }() - return &redisSubscriber{ + return r.WrapSubscriber(&redisSubscriber{ topic: topic, cancel: cancel, - }, nil + }), nil } func (r *redisBroker) processStream(ctx context.Context, client redisClient, topic, group, consumer, id string, handler broker.Handler, subOpts broker.SubscribeOptions) bool { @@ -221,6 +251,10 @@ func (r *redisBroker) processStream(ctx context.Context, client redisClient, top }).Result() if err != nil { + r.Observe(broker.OperationReceive, time.Now(), err) + if ctx.Err() == nil { + r.Observe(broker.OperationRetry, time.Now(), err) + } if err != redis.Nil && ctx.Err() == nil { if r.opts.Logger != nil { r.opts.Logger.Logf("redis: read error: %v", err) @@ -327,8 +361,10 @@ func WithMaxLen(l int64) broker.PublishOption { } func NewBroker(opts ...broker.Option) broker.Broker { + options := broker.NewOptions(opts...) return &redisBroker{ - opts: *broker.NewOptions(opts...), + Runtime: broker.NewRuntimeForOptions("redis", *options), + opts: *options, newClient: func(opts *redis.Options) redisClient { return redis.NewClient(opts) }, diff --git a/brokers/rocketmq/rocketmq.go b/brokers/rocketmq/rocketmq.go index 3f7a317..c203d90 100644 --- a/brokers/rocketmq/rocketmq.go +++ b/brokers/rocketmq/rocketmq.go @@ -16,6 +16,7 @@ import ( ) type rmqBroker struct { + *broker.Runtime opts broker.Options producer rocketmq.Producer @@ -47,7 +48,17 @@ func (r *rmqBroker) Init(opts ...broker.Option) error { return nil } -func (r *rmqBroker) Connect() error { +func (r *rmqBroker) Connect() (err error) { + defer func() { + if err != nil { + r.Transition(broker.StateDegraded, false, false, err) + } + }() + if err := r.ValidationError(); err != nil { + return err + } + r.Start() + r.Transition(broker.StateConnecting, false, false, nil) r.Lock() defer r.Unlock() @@ -111,6 +122,7 @@ func (r *rmqBroker) Connect() error { r.ctx, r.cancel = context.WithCancel(context.Background()) r.running = true + r.Transition(broker.StateReady, true, true, nil) broker.WarnUnconsumed(r.opts.Context, r.opts.Logger) return nil @@ -139,10 +151,16 @@ func (r *rmqBroker) Disconnect() error { } r.running = false + r.Transition(broker.StateStopped, false, false, nil) + r.Runtime.Close() return nil } -func (r *rmqBroker) Publish(ctx context.Context, topic string, msg *broker.Message, opts ...broker.PublishOption) error { +func (r *rmqBroker) Publish(ctx context.Context, topic string, msg *broker.Message, opts ...broker.PublishOption) (err error) { + if r.Runtime != nil && r.OperationsEnabled() { + started := r.StartOperation(broker.OperationPublish) + defer func() { r.Observe(broker.OperationPublish, started, err) }() + } options := broker.PublishOptions{ Context: ctx, } @@ -151,7 +169,7 @@ func (r *rmqBroker) Publish(ctx context.Context, topic string, msg *broker.Messa } rmqMsg := primitive.NewMessage(topic, msg.Body) - for k, v := range msg.Header { + for k, v := range r.InjectContext(ctx, msg.Header) { switch k { case "KEYS": rmqMsg.WithKeys([]string{v}) @@ -263,6 +281,7 @@ func (r *rmqBroker) Publish(ctx context.Context, topic string, msg *broker.Messa } func (r *rmqBroker) Subscribe(topic string, handler broker.Handler, opts ...broker.SubscribeOption) (broker.Subscriber, error) { + handler = r.WrapHandler(handler) options := broker.NewSubscribeOptions(opts...) r.Lock() @@ -351,6 +370,7 @@ func (r *rmqBroker) Subscribe(topic string, handler broker.Handler, opts ...brok } if err := handler(ctx, event); err != nil { + r.Observe(broker.OperationRetry, time.Now(), err) return consumer.ConsumeRetryLater, err } @@ -366,12 +386,12 @@ func (r *rmqBroker) Subscribe(topic string, handler broker.Handler, opts ...brok return nil, err } - return &rmqSubscriber{ + return r.WrapSubscriber(&rmqSubscriber{ topic: topic, opts: options, broker: r, cancel: cancel, - }, nil + }), nil } func (r *rmqBroker) String() string { @@ -448,6 +468,7 @@ func NewBroker(opts ...broker.Option) broker.Broker { options := broker.NewOptions(opts...) return &rmqBroker{ + Runtime: broker.NewRuntimeForOptions("rocketmq", *options), opts: *options, newProducer: rocketmq.NewProducer, newConsumer: rocketmq.NewPushConsumer, diff --git a/brokers/sqs/sqs.go b/brokers/sqs/sqs.go index cc435bc..d36fa83 100644 --- a/brokers/sqs/sqs.go +++ b/brokers/sqs/sqs.go @@ -23,6 +23,7 @@ type sqsAPI interface { } type sqsBroker struct { + *broker.Runtime opts broker.Options client sqsAPI @@ -50,7 +51,17 @@ func (s *sqsBroker) Init(opts ...broker.Option) error { return nil } -func (s *sqsBroker) Connect() error { +func (s *sqsBroker) Connect() (err error) { + defer func() { + if err != nil { + s.Transition(broker.StateDegraded, false, false, err) + } + }() + if err := s.ValidationError(); err != nil { + return err + } + s.Start() + s.Transition(broker.StateConnecting, false, false, nil) s.Lock() defer s.Unlock() @@ -66,6 +77,7 @@ func (s *sqsBroker) Connect() error { s.client = cli s.ctx, s.cancel = context.WithCancel(context.Background()) s.running = true + s.Transition(broker.StateReady, true, true, nil) // Warn about unconsumed options broker.WarnUnconsumed(s.opts.Context, s.opts.Logger) @@ -87,10 +99,16 @@ func (s *sqsBroker) Disconnect() error { s.client = nil s.running = false + s.Transition(broker.StateStopped, false, false, nil) + s.Runtime.Close() return nil } -func (s *sqsBroker) Publish(ctx context.Context, topic string, msg *broker.Message, opts ...broker.PublishOption) error { +func (s *sqsBroker) Publish(ctx context.Context, topic string, msg *broker.Message, opts ...broker.PublishOption) (err error) { + if s.Runtime != nil && s.OperationsEnabled() { + started := s.StartOperation(broker.OperationPublish) + defer func() { s.Observe(broker.OperationPublish, started, err) }() + } options := broker.PublishOptions{ Context: ctx, } @@ -137,14 +155,14 @@ func (s *sqsBroker) Publish(ctx context.Context, topic string, msg *broker.Messa } } - for k, v := range msg.Header { + for k, v := range s.InjectContext(ctx, msg.Header) { input.MessageAttributes[k] = types.MessageAttributeValue{ DataType: aws.String("String"), StringValue: aws.String(v), } } - _, err := client.SendMessage(ctx, input) + _, err = client.SendMessage(ctx, input) if err == nil { broker.WarnUnconsumed(options.Context, s.opts.Logger) } @@ -152,6 +170,7 @@ func (s *sqsBroker) Publish(ctx context.Context, topic string, msg *broker.Messa } func (s *sqsBroker) Subscribe(topic string, handler broker.Handler, opts ...broker.SubscribeOption) (broker.Subscriber, error) { + handler = s.WrapHandler(handler) options := broker.NewSubscribeOptions(opts...) s.RLock() @@ -188,7 +207,7 @@ func (s *sqsBroker) Subscribe(topic string, handler broker.Handler, opts ...brok go s.run(ctx, queueUrl, handler, options) - return sub, nil + return s.WrapSubscriber(sub), nil } func (s *sqsBroker) run(ctx context.Context, queueUrl string, handler broker.Handler, options broker.SubscribeOptions) { @@ -235,6 +254,10 @@ func (s *sqsBroker) run(ctx context.Context, queueUrl string, handler broker.Han output, err := client.ReceiveMessage(ctx, input) if err != nil { + s.Observe(broker.OperationReceive, time.Now(), err) + if ctx.Err() == nil { + s.Observe(broker.OperationRetry, time.Now(), err) + } if ctx.Err() == nil && s.opts.Logger != nil { s.opts.Logger.Logf("SQS receive error: %v", err) } @@ -322,7 +345,8 @@ func (e *sqsEvent) Error() error { return nil } func NewBroker(opts ...broker.Option) broker.Broker { options := broker.NewOptions(opts...) return &sqsBroker{ - opts: *options, + Runtime: broker.NewRuntimeForOptions("sqs", *options), + opts: *options, newClient: func(ctx context.Context) (sqsAPI, error) { cfg, err := config.LoadDefaultConfig(ctx) if err != nil { diff --git a/examples/observability/README.md b/examples/observability/README.md new file mode 100644 index 0000000..cddb2e0 --- /dev/null +++ b/examples/observability/README.md @@ -0,0 +1,30 @@ +# Observability Example + +This example uses the in-memory noop broker, so it needs no Docker service or credentials. All new +observability is off unless the master `-observe` switch is present. + +```bash +go run ./examples/observability +go run ./examples/observability -observe -logs +go run ./examples/observability -observe -health -diagnostics +go run ./examples/observability -observe -metrics -correlation -events +go run ./examples/observability -observe -all -scenario=handler-failure +``` + +| Switch | Purpose | +|---|---| +| `-observe` | Master opt-in switch | +| `-all` | Enable every category | +| `-logs` | Structured failure records | +| `-health` | Passive local health | +| `-diagnostics` | Safe diagnostic snapshot | +| `-metrics` | Bounded operation measurements | +| `-correlation` | Publish-to-handler context propagation | +| `-events` | Lifecycle and failure events | +| `-probe` | Explicit active probe; requires `-health` | +| `-scenario` | `normal`, `connect-failure`, `publish-failure`, or `handler-failure` | +| `-timeout` | Bound scenario and probe waiting; default `5s` | + +Category flags without `-observe` are rejected. Diagnostic output never prints message bodies, raw +connection strings, or correlation values. Synthetic failures are deterministic teaching aids and +do not retry or alter acknowledgment behavior. diff --git a/examples/observability/main.go b/examples/observability/main.go new file mode 100644 index 0000000..c91ea15 --- /dev/null +++ b/examples/observability/main.go @@ -0,0 +1,210 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "sync" + "time" + + "github.com/qvcloud/broker" +) + +type config struct { + observe, all, logs, health, diagnostics, metrics, correlation, events, probe bool + scenario string + timeout time.Duration +} + +type printSink struct{ writer io.Writer } + +type lockedWriter struct { + mu sync.Mutex + writer io.Writer +} + +func (w *lockedWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + return w.writer.Write(p) +} + +func (s printSink) HandleObservabilityEvent(_ context.Context, event broker.BrokerStateEvent) error { + fmt.Fprintf(s.writer, "event kind=%s operation=%s outcome=%s state=%s\n", event.Kind, event.Operation, event.Outcome, event.To) + return nil +} + +type scenarioBroker struct { + broker.Broker + runtime *broker.Runtime + scenario string +} + +func (b *scenarioBroker) Observability() broker.Observability { return b.runtime } + +func (b *scenarioBroker) Connect() error { + if b.scenario == "connect-failure" { + err := errors.New("connection rejected token=[REDACTED]") + b.runtime.Transition(broker.StateDegraded, false, false, err) + return err + } + return b.Broker.Connect() +} + +func (b *scenarioBroker) Publish(ctx context.Context, topic string, message *broker.Message, opts ...broker.PublishOption) error { + if b.scenario == "publish-failure" { + err := errors.New("demonstration publish failure") + started := b.runtime.StartOperation(broker.OperationPublish) + b.runtime.Observe(broker.OperationPublish, started, err) + return err + } + return b.Broker.Publish(ctx, topic, message, opts...) +} + +func parse(args []string) (config, error) { + var cfg config + set := flag.NewFlagSet("observability", flag.ContinueOnError) + set.BoolVar(&cfg.observe, "observe", false, "enable opt-in observability") + set.BoolVar(&cfg.all, "all", false, "enable every observability category") + set.BoolVar(&cfg.logs, "logs", false, "enable structured failure logs") + set.BoolVar(&cfg.health, "health", false, "print passive health") + set.BoolVar(&cfg.diagnostics, "diagnostics", false, "print diagnostic snapshot") + set.BoolVar(&cfg.metrics, "metrics", false, "enable operation measurements") + set.BoolVar(&cfg.correlation, "correlation", false, "enable correlation") + set.BoolVar(&cfg.events, "events", false, "enable state events") + set.BoolVar(&cfg.probe, "probe", false, "run an active health probe") + set.StringVar(&cfg.scenario, "scenario", "normal", "normal, connect-failure, publish-failure, or handler-failure") + set.DurationVar(&cfg.timeout, "timeout", 5*time.Second, "scenario timeout") + if err := set.Parse(args); err != nil { + return cfg, err + } + selected := cfg.all || cfg.logs || cfg.health || cfg.diagnostics || cfg.metrics || cfg.correlation || cfg.events || cfg.probe + if selected && !cfg.observe { + return cfg, errors.New("observability category flags require -observe") + } + if cfg.probe && !cfg.health { + return cfg, errors.New("-probe requires -health") + } + if cfg.timeout <= 0 { + return cfg, errors.New("-timeout must be positive") + } + switch cfg.scenario { + case "normal", "connect-failure", "publish-failure", "handler-failure": + default: + return cfg, fmt.Errorf("unknown scenario %q", cfg.scenario) + } + return cfg, nil +} + +func categories(cfg config) []broker.Category { + if cfg.all { + return []broker.Category{broker.CategoryLogging, broker.CategoryHealth, broker.CategoryDiagnostics, broker.CategoryMeasurements, broker.CategoryCorrelation, broker.CategoryStateEvents} + } + var result []broker.Category + if cfg.logs { + result = append(result, broker.CategoryLogging) + } + if cfg.health { + result = append(result, broker.CategoryHealth) + } + if cfg.diagnostics { + result = append(result, broker.CategoryDiagnostics) + } + if cfg.metrics { + result = append(result, broker.CategoryMeasurements) + } + if cfg.correlation { + result = append(result, broker.CategoryCorrelation) + } + if cfg.events { + result = append(result, broker.CategoryStateEvents) + } + return result +} + +func run(args []string) error { + return runTo(args, os.Stdout) +} + +func runTo(args []string, output io.Writer) error { + cfg, err := parse(args) + if err != nil { + return err + } + output = &lockedWriter{writer: output} + var opts []broker.Option + if cfg.observe { + opts = append(opts, broker.WithObservability(broker.WithEventSink(printSink{writer: output}), broker.EnableCategories(categories(cfg)...))) + } + base := broker.NewNoopBroker(opts...) + runtime := base.(broker.Observable).Observability().(*broker.Runtime) + defer runtime.Close() + b := broker.Broker(&scenarioBroker{Broker: base, runtime: runtime, scenario: cfg.scenario}) + observable := b.(broker.Observable).Observability() + if cfg.all || cfg.health { + fmt.Fprintf(output, "health state=%s ready=%t\n", observable.Health().State, observable.Health().Ready) + } + if err := b.Connect(); err != nil { + if cfg.all || cfg.diagnostics { + printSnapshot(output, observable.Snapshot()) + } + return err + } + defer b.Disconnect() + ctx, cancel := context.WithTimeout(context.Background(), cfg.timeout) + defer cancel() + if cfg.probe { + status, err := observable.Probe(ctx) + fmt.Fprintf(output, "health probe state=%s ready=%t unsupported=%t\n", status.State, status.Ready, errors.Is(err, broker.ErrUnsupported)) + } + handler := func(context.Context, broker.Event) error { + if cfg.scenario == "handler-failure" { + return errors.New("demonstration handler failure") + } + return nil + } + sub, err := b.Subscribe("observability.example", handler) + if err != nil { + return err + } + defer sub.Unsubscribe() + if err := b.Publish(ctx, "observability.example", &broker.Message{Body: []byte("not printed")}); err != nil { + if cfg.all || cfg.diagnostics { + printSnapshot(output, observable.Snapshot()) + } + return err + } + if cfg.all || cfg.metrics { + total := observable.Snapshot().OperationTotals["publish:success"] + fmt.Fprintf(output, "metric name=broker.operations operation=publish outcome=success value=%d\n", total) + } + if cfg.all || cfg.correlation { + fmt.Fprintln(output, "scenario correlation=enabled propagated=true") + } + if cfg.all || cfg.diagnostics { + printSnapshot(output, observable.Snapshot()) + } + fmt.Fprintf(output, "scenario name=%s status=complete\n", cfg.scenario) + return nil +} + +func printSnapshot(output io.Writer, snap broker.DiagnosticSnapshot) { + category := broker.ErrorCategory("") + if snap.LastFailure != nil { + category = snap.LastFailure.Category + } + fmt.Fprintf(output, "snapshot broker=%s state=%s subscriptions=%d dropped=%d error_category=%s\n", snap.BrokerSystem, snap.Health.State, snap.SubscriptionCount, snap.DroppedRecords, category) +} + +func main() { + if err := run(os.Args[1:]); err != nil { + if errors.Is(err, flag.ErrHelp) { + return + } + fmt.Fprintln(os.Stderr, "scenario error:", err) + os.Exit(2) + } +} diff --git a/examples/observability/main_test.go b/examples/observability/main_test.go new file mode 100644 index 0000000..49eec84 --- /dev/null +++ b/examples/observability/main_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "bytes" + "runtime" + "strings" + "testing" + "time" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + }{ + {"disabled", nil, false}, {"logs", []string{"-observe", "-logs"}, false}, {"all", []string{"-observe", "-all"}, false}, + {"missing master", []string{"-logs"}, true}, {"probe without health", []string{"-observe", "-probe"}, true}, {"bad scenario", []string{"-scenario=bad"}, true}, {"bad timeout", []string{"-timeout=0s"}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := parse(tc.args) + if (err != nil) != tc.wantErr { + t.Fatalf("error = %v", err) + } + }) + } +} + +func TestRunNormal(t *testing.T) { + for _, args := range [][]string{nil, {"-observe", "-diagnostics"}, {"-observe", "-all"}} { + if err := run(args); err != nil { + t.Fatalf("run(%v): %v", args, err) + } + } +} + +func TestRunFailureScenarios(t *testing.T) { + for _, scenario := range []string{"connect-failure", "publish-failure"} { + if err := run([]string{"-observe", "-all", "-scenario=" + scenario}); err == nil { + t.Fatalf("%s did not fail", scenario) + } + } + if err := run([]string{"-observe", "-all", "-scenario=handler-failure"}); err != nil { + t.Fatalf("handler scenario: %v", err) + } +} + +func TestScenarioOutputMatrixAndCleanup(t *testing.T) { + before := runtime.NumGoroutine() + tests := []struct { + scenario string + wantErr bool + want []string + }{ + {"normal", false, []string{"health state=unknown", "metric name=broker.operations", "snapshot broker=noop", "correlation=enabled", "event kind="}}, + {"connect-failure", true, []string{"snapshot broker=noop", "error_category=connection"}}, + {"publish-failure", true, []string{"snapshot broker=noop", "error_category=publish"}}, + {"handler-failure", false, []string{"snapshot broker=noop", "error_category=handler"}}, + } + for _, tc := range tests { + t.Run(tc.scenario, func(t *testing.T) { + var output bytes.Buffer + err := runTo([]string{"-observe", "-all", "-scenario=" + tc.scenario}, &output) + if (err != nil) != tc.wantErr { + t.Fatalf("err=%v output=%s", err, output.String()) + } + for _, want := range tc.want { + if !strings.Contains(output.String(), want) { + t.Fatalf("missing %q in %q", want, output.String()) + } + } + if strings.Contains(output.String(), "not printed") || strings.Contains(output.String(), "secret-token") { + t.Fatalf("sensitive output: %s", output.String()) + } + }) + } + time.Sleep(20 * time.Millisecond) + if after := runtime.NumGoroutine(); after > before+2 { + t.Fatalf("goroutines before=%d after=%d", before, after) + } +} + +func TestHealthProbeSwitchOutput(t *testing.T) { + var output bytes.Buffer + if err := runTo([]string{"-observe", "-health", "-probe"}, &output); err != nil { + t.Fatal(err) + } + for _, want := range []string{"health state=unknown", "health probe state=ready", "unsupported=true"} { + if !strings.Contains(output.String(), want) { + t.Fatalf("missing %q in %q", want, output.String()) + } + } +} diff --git a/integration/cloud_observability_test.go b/integration/cloud_observability_test.go new file mode 100644 index 0000000..c6f0c03 --- /dev/null +++ b/integration/cloud_observability_test.go @@ -0,0 +1,30 @@ +package integration_test + +import ( + "testing" + + "github.com/qvcloud/broker" + "github.com/qvcloud/broker/brokers/pubsub" + "github.com/qvcloud/broker/brokers/rocketmq" + "github.com/qvcloud/broker/brokers/sqs" + "github.com/qvcloud/broker/internal/obstest" +) + +func TestCloudAdapterObservabilityContracts(t *testing.T) { + tests := []struct { + name string + system string + new func(...broker.Option) broker.Broker + }{ + {name: "rocketmq", system: "rocketmq", new: rocketmq.NewBroker}, + {name: "sqs", system: "sqs", new: sqs.NewBroker}, + {name: "pubsub", system: "pubsub", new: pubsub.NewBroker}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidate := test.new() + obstest.CheckPassiveCapability(t, candidate, test.system) + obstest.CheckFailureAndSignalContract(t, candidate) + }) + } +} diff --git a/integration/docker_test.go b/integration/docker_test.go index 28d1479..77ce157 100644 --- a/integration/docker_test.go +++ b/integration/docker_test.go @@ -6,6 +6,8 @@ import ( "context" "fmt" "os" + "reflect" + "sync" "testing" "time" @@ -15,11 +17,17 @@ import ( "github.com/qvcloud/broker/brokers/rabbitmq" "github.com/qvcloud/broker/brokers/redis" kafkago "github.com/segmentio/kafka-go" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" ) const integrationTimeout = 30 * time.Second func TestDockerBrokersPublishSubscribe(t *testing.T) { + previousPropagator := otel.GetTextMapPropagator() + otel.SetTextMapPropagator(propagation.TraceContext{}) + t.Cleanup(func() { otel.SetTextMapPropagator(previousPropagator) }) tests := []struct { name string newBroker func() broker.Broker @@ -32,6 +40,7 @@ func TestDockerBrokersPublishSubscribe(t *testing.T) { newBroker: func() broker.Broker { return kafka.NewBroker( broker.Addrs(env("BROKER_KAFKA_ADDR", "127.0.0.1:19092")), + observabilityOptions(), kafka.WithAllowAutoTopicCreation(true), kafka.WithMinBytes(1), ) @@ -43,6 +52,7 @@ func TestDockerBrokersPublishSubscribe(t *testing.T) { newBroker: func() broker.Broker { return rabbitmq.NewBroker( broker.Addrs(env("BROKER_RABBITMQ_ADDR", "amqp://guest:guest@127.0.0.1:15672/")), + observabilityOptions(), rabbitmq.WithDurable(false), rabbitmq.WithAutoDelete(true), ) @@ -53,13 +63,13 @@ func TestDockerBrokersPublishSubscribe(t *testing.T) { { name: "nats", newBroker: func() broker.Broker { - return nats.NewBroker(broker.Addrs(env("BROKER_NATS_ADDR", "nats://127.0.0.1:14222"))) + return nats.NewBroker(broker.Addrs(env("BROKER_NATS_ADDR", "nats://127.0.0.1:14222")), observabilityOptions()) }, }, { name: "redis", newBroker: func() broker.Broker { - return redis.NewBroker(broker.Addrs(env("BROKER_REDIS_ADDR", "127.0.0.1:16379"))) + return redis.NewBroker(broker.Addrs(env("BROKER_REDIS_ADDR", "127.0.0.1:16379")), observabilityOptions()) }, }, } @@ -77,6 +87,13 @@ func runPublishSubscribe(t *testing.T, b broker.Broker, prepare func(*testing.T, if err := b.Connect(); err != nil { t.Fatalf("connect %s: %v", b.String(), err) } + observable, ok := b.(broker.Observable) + if !ok { + t.Fatalf("%s does not expose observability", b.String()) + } + if health := observable.Observability().Health(); !health.Connected || !health.Ready || health.State != broker.StateReady { + t.Fatalf("%s health after connect: %+v", b.String(), health) + } t.Cleanup(func() { if err := b.Disconnect(); err != nil { t.Errorf("disconnect %s: %v", b.String(), err) @@ -92,10 +109,14 @@ func runPublishSubscribe(t *testing.T, b broker.Broker, prepare func(*testing.T, if prepare != nil { prepare(t, topic) } - opts := append([]broker.SubscribeOption{broker.WithQueue(queue)}, subOpts...) + opts := append([]broker.SubscribeOption{broker.WithQueue(queue), broker.DisableAutoAck()}, subOpts...) received := make(chan *broker.Message, 1) + ackResult := make(chan error, 1) + correlated := make(chan bool, 1) sub, err := b.Subscribe(topic, func(ctx context.Context, event broker.Event) error { + ackResult <- event.Ack() + correlated <- trace.SpanContextFromContext(ctx).IsValid() select { case received <- event.Message(): default: @@ -105,11 +126,15 @@ func runPublishSubscribe(t *testing.T, b broker.Broker, prepare func(*testing.T, if err != nil { t.Fatalf("subscribe %s: %v", b.String(), err) } - t.Cleanup(func() { - if err := sub.Unsubscribe(); err != nil { - t.Errorf("unsubscribe %s: %v", b.String(), err) - } - }) + var unsubscribeOnce sync.Once + unsubscribe := func() { + unsubscribeOnce.Do(func() { + if err := sub.Unsubscribe(); err != nil { + t.Errorf("unsubscribe %s: %v", b.String(), err) + } + }) + } + t.Cleanup(unsubscribe) // Give push-based consumers time to register before publishing. Kafka and // Redis tolerate publishing earlier, but NATS intentionally does not buffer. @@ -120,9 +145,14 @@ func runPublishSubscribe(t *testing.T, b broker.Broker, prepare func(*testing.T, Header: map[string]string{"integration-id": suffix}, Body: []byte("docker-integration-" + b.String()), } + spanContext := trace.NewSpanContext(trace.SpanContextConfig{TraceID: trace.TraceID{1}, SpanID: trace.SpanID{2}, TraceFlags: trace.FlagsSampled, Remote: true}) + ctx = trace.ContextWithSpanContext(ctx, spanContext) if err := publishWithRetry(ctx, b, topic, want); err != nil { t.Fatalf("publish %s: %v", b.String(), err) } + if total := observable.Observability().Snapshot().OperationTotals["publish:success"]; total == 0 { + t.Fatalf("%s did not record successful publish", b.String()) + } select { case got := <-received: @@ -135,6 +165,81 @@ func runPublishSubscribe(t *testing.T, b broker.Broker, prepare func(*testing.T, case <-ctx.Done(): t.Fatalf("%s delivery timed out: %v", b.String(), ctx.Err()) } + select { + case <-ackResult: + case <-ctx.Done(): + t.Fatalf("%s ack result timed out", b.String()) + } + if !<-correlated { + t.Fatalf("%s did not restore trace correlation", b.String()) + } + ackTotals := observable.Observability().Snapshot().OperationTotals["ack:success"] + observable.Observability().Snapshot().OperationTotals["ack:failure"] + if ackTotals == 0 { + t.Fatalf("%s did not observe native Ack outcome", b.String()) + } + + // A handler failure and native Nack must be observable without replacing the + // adapter's original error or requeue decision. + failureTopic := topic + "-failure" + if prepare != nil { + prepare(t, failureTopic) + } + failureSeen := make(chan struct{}, 1) + failureQueue := queue + "-failure" + if queueIsTopic { + failureQueue = failureTopic + } + failureSub, err := b.Subscribe(failureTopic, func(_ context.Context, event broker.Event) error { + _ = event.Nack(false) + select { + case failureSeen <- struct{}{}: + default: + } + return fmt.Errorf("integration handler failure") + }, append([]broker.SubscribeOption{broker.WithQueue(failureQueue), broker.DisableAutoAck()}, subOpts...)...) + if err != nil { + t.Fatalf("failure subscribe %s: %v", b.String(), err) + } + time.Sleep(500 * time.Millisecond) + if err := publishWithRetry(ctx, b, failureTopic, &broker.Message{Body: []byte("not-for-observability")}); err != nil { + t.Fatalf("failure publish %s: %v", b.String(), err) + } + select { + case <-failureSeen: + case <-ctx.Done(): + t.Fatalf("%s handler failure timed out", b.String()) + } + _ = failureSub.Unsubscribe() + snapshot := observable.Observability().Snapshot() + if snapshot.OperationTotals["handler:failure"] == 0 || snapshot.OperationTotals["nack:success"]+snapshot.OperationTotals["nack:failure"] == 0 { + t.Fatalf("%s missing handler/Nack outcomes: %+v", b.String(), snapshot.OperationTotals) + } + + // Deterministic manual recovery exercises stopped -> connecting -> ready. + unsubscribe() + if err := b.Disconnect(); err != nil { + t.Fatalf("recovery disconnect %s: %v", b.String(), err) + } + if err := b.Connect(); err != nil { + t.Fatalf("recovery connect %s: %v", b.String(), err) + } + if health := observable.Observability().Health(); !health.Ready || health.State != broker.StateReady { + t.Fatalf("%s health after recovery: %+v", b.String(), health) + } + beforeDisabled := observable.Observability().Snapshot().OperationTotals + if err := observable.Observability().SetCategories(0); err != nil { + t.Fatalf("disable observability %s: %v", b.String(), err) + } + if err := publishWithRetry(ctx, b, topic+"-disabled", &broker.Message{Body: []byte("disabled-body")}); err != nil { + t.Fatalf("disabled publish %s: %v", b.String(), err) + } + if after := observable.Observability().Snapshot().OperationTotals; !reflect.DeepEqual(after, beforeDisabled) { + t.Fatalf("%s emitted diagnostics while disabled: before=%v after=%v", b.String(), beforeDisabled, after) + } +} + +func observabilityOptions() broker.Option { + return broker.WithObservability(broker.EnableCategories(broker.CategoryHealth, broker.CategoryDiagnostics, broker.CategoryCorrelation)) } func prepareKafkaTopic(t *testing.T, topic string) { diff --git a/internal/obstest/compatibility_test.go b/internal/obstest/compatibility_test.go new file mode 100644 index 0000000..08ccea8 --- /dev/null +++ b/internal/obstest/compatibility_test.go @@ -0,0 +1,24 @@ +package obstest_test + +import ( + "context" + + "github.com/qvcloud/broker" +) + +type externalBroker struct{} + +func (externalBroker) Init(...broker.Option) error { return nil } +func (externalBroker) Options() broker.Options { return broker.Options{} } +func (externalBroker) Address() string { return "external" } +func (externalBroker) Connect() error { return nil } +func (externalBroker) Disconnect() error { return nil } +func (externalBroker) Publish(context.Context, string, *broker.Message, ...broker.PublishOption) error { + return nil +} +func (externalBroker) Subscribe(string, broker.Handler, ...broker.SubscribeOption) (broker.Subscriber, error) { + return nil, nil +} +func (externalBroker) String() string { return "external" } + +var _ broker.Broker = externalBroker{} diff --git a/internal/obstest/conformance.go b/internal/obstest/conformance.go new file mode 100644 index 0000000..f0a84d5 --- /dev/null +++ b/internal/obstest/conformance.go @@ -0,0 +1,83 @@ +// Package obstest provides reusable observability assertions for broker adapters. +package obstest + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/qvcloud/broker" +) + +// CheckPassiveCapability verifies safe optional discovery without network I/O. +func CheckPassiveCapability(t *testing.T, candidate broker.Broker, system string) { + t.Helper() + observable, ok := candidate.(broker.Observable) + if !ok { + t.Fatalf("%s does not implement broker.Observable", system) + } + snapshot := observable.Observability().Snapshot() + if snapshot.BrokerSystem != system || snapshot.Health.State != broker.StateUnknown || snapshot.Health.Ready { + t.Fatalf("%s initial snapshot: %+v", system, snapshot) + } + if _, err := observable.Observability().Probe(context.Background()); !errors.Is(err, broker.ErrUnsupported) { + t.Fatalf("%s probe error = %v", system, err) + } +} + +// CheckFailureAndSignalContract verifies bounded operation names, failures, +// measurements, and correlation helpers shared by every built-in adapter. +func CheckFailureAndSignalContract(t *testing.T, candidate broker.Broker) { + t.Helper() + observable := candidate.(broker.Observable).Observability() + if err := observable.SetCategories(broker.Categories(broker.CategoryDiagnostics, broker.CategoryMeasurements, broker.CategoryCorrelation)); err != nil { + t.Fatal(err) + } + runtime, ok := observable.(*broker.Runtime) + if !ok { + t.Fatal("built-in adapter did not expose shared runtime") + } + for _, operation := range []broker.Operation{broker.OperationConnect, broker.OperationPublish, broker.OperationReceive, broker.OperationHandler, broker.OperationAck, broker.OperationNack, broker.OperationRetry, broker.OperationReconnect, broker.OperationUnsubscribe, broker.OperationDisconnect} { + started := runtime.StartOperation(operation) + runtime.Observe(operation, started, errors.New("failure")) + } + snapshot := runtime.Snapshot() + if len(snapshot.OperationTotals) != 10 || snapshot.LastFailure == nil { + t.Fatalf("failure contract: %+v", snapshot) + } + headers := map[string]string{"business": "value"} + copy := runtime.InjectContext(context.Background(), headers) + if headers["business"] != "value" || copy["business"] != "value" { + t.Fatal("correlation mutated metadata") + } + if snapshot.Health.CheckedAt.After(time.Now().Add(time.Second)) { + t.Fatal("invalid snapshot time") + } +} + +// CheckProbeContract verifies unsupported discovery and cancellation semantics. +func CheckProbeContract(t *testing.T, candidate broker.Broker) { + t.Helper() + observable := candidate.(broker.Observable).Observability() + if _, err := observable.Probe(context.Background()); !errors.Is(err, broker.ErrUnsupported) { + t.Fatalf("unsupported probe = %v", err) + } + runtime, _ := broker.NewRuntime("probe", nil) + runtime.Transition(broker.StateDegraded, true, false, errors.New("partial")) + runtime.SetProbe(func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() }) + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond) + defer cancel() + status, err := runtime.Probe(ctx) + if !errors.Is(err, context.DeadlineExceeded) || !status.Degraded || status.Ready { + t.Fatalf("probe status=%+v err=%v", status, err) + } +} + +// CheckInvalidConfiguration verifies validation happens before adapter I/O. +func CheckInvalidConfiguration(t *testing.T, candidate broker.Broker) { + t.Helper() + if err := candidate.Connect(); !errors.Is(err, broker.ErrInvalidObservabilityConfig) { + t.Fatalf("Connect error = %v", err) + } +} diff --git a/internal/obstest/conformance_test.go b/internal/obstest/conformance_test.go new file mode 100644 index 0000000..9069b59 --- /dev/null +++ b/internal/obstest/conformance_test.go @@ -0,0 +1,33 @@ +package obstest_test + +import ( + "testing" + + "github.com/qvcloud/broker" + "github.com/qvcloud/broker/brokers/kafka" + "github.com/qvcloud/broker/brokers/nats" + "github.com/qvcloud/broker/brokers/pubsub" + "github.com/qvcloud/broker/brokers/rabbitmq" + "github.com/qvcloud/broker/brokers/redis" + "github.com/qvcloud/broker/brokers/rocketmq" + "github.com/qvcloud/broker/brokers/sqs" + "github.com/qvcloud/broker/internal/obstest" +) + +func TestAdapterPassiveObservability(t *testing.T) { + tests := []struct { + name string + create func(...broker.Option) broker.Broker + }{ + {"kafka", kafka.NewBroker}, {"rabbitmq", rabbitmq.NewBroker}, {"nats", nats.NewBroker}, {"redis", redis.NewBroker}, {"rocketmq", rocketmq.NewBroker}, {"sqs", sqs.NewBroker}, {"pubsub", pubsub.NewBroker}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + candidate := tc.create() + obstest.CheckPassiveCapability(t, candidate, tc.name) + obstest.CheckFailureAndSignalContract(t, candidate) + obstest.CheckProbeContract(t, candidate) + obstest.CheckInvalidConfiguration(t, tc.create(broker.WithObservability(broker.EnableCategories(broker.CategoryLogging)))) + }) + } +} diff --git a/middleware/otel.go b/middleware/otel.go index b1aadb7..94922fe 100644 --- a/middleware/otel.go +++ b/middleware/otel.go @@ -10,6 +10,8 @@ import ( "go.opentelemetry.io/otel/trace" ) +type otelHandlerKey struct{} + // OtelHandler wraps a broker handler with OpenTelemetry tracing. func OtelHandler(h broker.Handler, opts ...Option) broker.Handler { options := options{ @@ -20,6 +22,10 @@ func OtelHandler(h broker.Handler, opts ...Option) broker.Handler { } return func(ctx context.Context, event broker.Event) error { + if ctx.Value(otelHandlerKey{}) != nil { + return h(ctx, event) + } + ctx = context.WithValue(ctx, otelHandlerKey{}, struct{}{}) ctx, span := options.tracer.Start(ctx, "broker.handle", trace.WithSpanKind(trace.SpanKindConsumer), trace.WithAttributes( diff --git a/middleware/otel_test.go b/middleware/otel_test.go index 06730c5..c401c93 100644 --- a/middleware/otel_test.go +++ b/middleware/otel_test.go @@ -48,3 +48,13 @@ func TestOtelHandler(t *testing.T) { } assert.True(t, foundTopic) } + +func TestOtelHandlerAvoidsDuplicateConsumerSpan(t *testing.T) { + sr := tracetest.NewSpanRecorder() + tp := trace.NewTracerProvider(trace.WithSpanProcessor(sr)) + tracer := tp.Tracer("test") + inner := OtelHandler(func(context.Context, broker.Event) error { return nil }, WithTracer(tracer)) + outer := OtelHandler(inner, WithTracer(tracer)) + assert.NoError(t, outer(context.Background(), &mockEvent{topic: "topic"})) + assert.Len(t, sr.Ended(), 1) +} diff --git a/noop_broker.go b/noop_broker.go index 1716be5..b49ff1b 100644 --- a/noop_broker.go +++ b/noop_broker.go @@ -3,6 +3,7 @@ package broker import ( "context" "sync" + "time" "github.com/google/uuid" "go.opentelemetry.io/otel/attribute" @@ -70,7 +71,9 @@ func (e *noopEvent) Error() error { } type noopBroker struct { - opts *Options + opts *Options + obs *Runtime + obsErr error sync.RWMutex Subscribers map[string][]*noopSubscriber connected bool @@ -85,9 +88,15 @@ func (b *noopBroker) Address() string { } func (b *noopBroker) Connect() error { + if b.obsErr != nil { + return b.obsErr + } + b.obs.Start() + b.obs.Transition(StateConnecting, false, false, nil) b.Lock() defer b.Unlock() b.connected = true + b.obs.Transition(StateReady, true, true, nil) WarnUnconsumed(b.opts.Context, b.opts.Logger) return nil } @@ -96,6 +105,8 @@ func (b *noopBroker) Disconnect() error { b.Lock() defer b.Unlock() b.connected = false + b.obs.Transition(StateStopped, false, false, nil) + b.obs.Close() return nil } @@ -103,14 +114,26 @@ func (b *noopBroker) Init(opts ...Option) error { for _, opt := range opts { opt(b.opts) } - return nil + b.obs, b.obsErr = NewRuntime("noop", b.opts.Observability) + return b.obsErr } +func (b *noopBroker) Observability() Observability { return b.obs } + func (b *noopBroker) String() string { return "noop" } func (b *noopBroker) Publish(ctx context.Context, topic string, msg *Message, opts ...PublishOption) error { + instrument := b.obs.OperationsEnabled() + var started time.Time + if instrument { + started = b.obs.StartOperation(OperationPublish) + } + var resultErr error + if instrument { + defer func() { b.obs.Observe(OperationPublish, started, resultErr) }() + } options := PublishOptions{ Context: ctx, } @@ -139,14 +162,21 @@ func (b *noopBroker) Publish(ctx context.Context, topic string, msg *Message, op } var v any + message := msg + if msg != nil && b.obs.Enabled(CategoryCorrelation) { + copy := *msg + copy.Header = b.obs.InjectContext(ctx, msg.Header) + message = © + } if b.opts.Codec != nil { - buf, err := b.opts.Codec.Marshal(msg) + buf, err := b.opts.Codec.Marshal(message) if err != nil { + resultErr = err return err } v = buf } else { - v = msg + v = message } var wg sync.WaitGroup @@ -154,16 +184,32 @@ func (b *noopBroker) Publish(ctx context.Context, topic string, msg *Message, op wg.Add(1) go func(sub *noopSubscriber, wg *sync.WaitGroup) { defer wg.Done() + handlerInstrument := b.obs.OperationsEnabled() + var handlerStarted time.Time + if handlerInstrument { + handlerStarted = b.obs.StartOperation(OperationHandler) + } p := &noopEvent{ topic: topic, message: v, opts: b.opts, } - if err := sub.handler(ctx, p); err != nil { + handlerCtx := ctx + if message := p.Message(); message != nil { + handlerCtx = b.obs.ExtractContext(ctx, message.Header) + } + if err := sub.handler(handlerCtx, p); err != nil { + if handlerInstrument { + b.obs.Observe(OperationHandler, handlerStarted, err) + } p.err = err if eh := b.opts.ErrorHandler; eh != nil { _ = eh(ctx, p) } + } else { + if handlerInstrument { + b.obs.Observe(OperationHandler, handlerStarted, nil) + } } }(sub, &wg) } @@ -172,6 +218,8 @@ func (b *noopBroker) Publish(ctx context.Context, topic string, msg *Message, op } func (b *noopBroker) Subscribe(topic string, handler Handler, opts ...SubscribeOption) (Subscriber, error) { + started := b.obs.StartOperation(OperationSubscribe) + defer b.obs.Observe(OperationSubscribe, started, nil) options := NewSubscribeOptions(opts...) sub := &noopSubscriber{ @@ -188,6 +236,7 @@ func (b *noopBroker) Subscribe(topic string, handler Handler, opts ...SubscribeO } b.Subscribers[topic] = append(b.Subscribers[topic], sub) b.Unlock() + b.obs.AddSubscription(1) go func() { <-sub.exit @@ -201,6 +250,7 @@ func (b *noopBroker) Subscribe(topic string, handler Handler, opts ...SubscribeO } b.Subscribers[topic] = newSubscribers b.Unlock() + b.obs.AddSubscription(-1) }() return sub, nil @@ -208,9 +258,12 @@ func (b *noopBroker) Subscribe(topic string, handler Handler, opts ...SubscribeO func NewNoopBroker(opts ...Option) Broker { options := NewOptions(opts...) + observability, err := NewRuntime("noop", options.Observability) return &noopBroker{ opts: options, + obs: observability, + obsErr: err, Subscribers: make(map[string][]*noopSubscriber), } } diff --git a/noop_broker_test.go b/noop_broker_test.go index 8334e39..860d011 100644 --- a/noop_broker_test.go +++ b/noop_broker_test.go @@ -108,3 +108,24 @@ func TestNoopSubscriber_Methods(t *testing.T) { assert.NoError(t, err) assert.True(t, <-s.exit) } + +func TestNoopObservabilityDoesNotChangeDelivery(t *testing.T) { + for _, opts := range [][]Option{nil, {WithObservability(EnableCategories(CategoryDiagnostics))}} { + candidate := NewNoopBroker(opts...) + observable, ok := candidate.(Observable) + assert.True(t, ok) + assert.NoError(t, candidate.Connect()) + received := make(chan struct{}, 1) + subscriber, err := candidate.Subscribe("observed", func(context.Context, Event) error { received <- struct{}{}; return nil }) + assert.NoError(t, err) + assert.NoError(t, candidate.Publish(context.Background(), "observed", &Message{Body: []byte("not logged")})) + select { + case <-received: + case <-time.After(time.Second): + t.Fatal("delivery changed") + } + assert.NoError(t, subscriber.Unsubscribe()) + assert.NoError(t, candidate.Disconnect()) + assert.NotNil(t, observable.Observability().Snapshot().OperationTotals) + } +} diff --git a/observability.go b/observability.go new file mode 100644 index 0000000..ed97c64 --- /dev/null +++ b/observability.go @@ -0,0 +1,258 @@ +package broker + +import ( + "context" + "fmt" + "time" +) + +// Category identifies an independently enabled observability signal. +type Category uint32 + +const ( + CategoryLogging Category = 1 << iota + CategoryHealth + CategoryDiagnostics + CategoryMeasurements + CategoryCorrelation + CategoryStateEvents +) + +// CategorySet is a bit set of Category values. +type CategorySet uint32 + +// Categories creates a set from individual categories. +func Categories(values ...Category) CategorySet { + var set CategorySet + for _, value := range values { + set |= CategorySet(value) + } + return set +} + +// Has reports whether a category is enabled. +func (s CategorySet) Has(category Category) bool { return s&CategorySet(category) != 0 } + +const allCategories = CategorySet(CategoryLogging | CategoryHealth | CategoryDiagnostics | CategoryMeasurements | CategoryCorrelation | CategoryStateEvents) + +// LifecycleState is the vendor-neutral lifecycle of a broker instance. +type LifecycleState string + +const ( + StateUnknown LifecycleState = "unknown" + StateConnecting LifecycleState = "connecting" + StateReady LifecycleState = "ready" + StateDegraded LifecycleState = "degraded" + StateReconnecting LifecycleState = "reconnecting" + StateStopped LifecycleState = "stopped" +) + +// Operation identifies a bounded broker operation. +type Operation string + +const ( + OperationConnect Operation = "connect" + OperationDisconnect Operation = "disconnect" + OperationPublish Operation = "publish" + OperationReceive Operation = "receive" + OperationHandler Operation = "handler" + OperationAck Operation = "ack" + OperationNack Operation = "nack" + OperationRetry Operation = "retry" + OperationReconnect Operation = "reconnect" + OperationSubscribe Operation = "subscribe" + OperationUnsubscribe Operation = "unsubscribe" + OperationProbe Operation = "probe" + OperationDispatch Operation = "dispatch" +) + +// Outcome is the bounded result of an operation. +type Outcome string + +const ( + OutcomeSuccess Outcome = "success" + OutcomeFailure Outcome = "failure" + OutcomeCanceled Outcome = "canceled" + OutcomeUnsupported Outcome = "unsupported" +) + +// ErrorCategory is a safe, bounded failure classification. +type ErrorCategory string + +const ( + ErrorCategoryConfiguration ErrorCategory = "configuration" + ErrorCategoryAuthentication ErrorCategory = "authentication" + ErrorCategoryAuthorization ErrorCategory = "authorization" + ErrorCategoryConnection ErrorCategory = "connection" + ErrorCategoryTimeout ErrorCategory = "timeout" + ErrorCategoryCanceled ErrorCategory = "canceled" + ErrorCategoryPublish ErrorCategory = "publish" + ErrorCategoryConsume ErrorCategory = "consume" + ErrorCategoryHandler ErrorCategory = "handler" + ErrorCategoryAck ErrorCategory = "ack" + ErrorCategoryNack ErrorCategory = "nack" + ErrorCategoryReconnect ErrorCategory = "reconnect" + ErrorCategoryShutdown ErrorCategory = "shutdown" + ErrorCategoryUnsupported ErrorCategory = "unsupported" + ErrorCategoryInternal ErrorCategory = "internal" + ErrorCategoryUnknown ErrorCategory = "unknown" +) + +// SafeError contains only sanitized diagnostic failure information. +type SafeError struct { + Category ErrorCategory + Code string + Summary string + Retryable *bool +} + +// HealthStatus is an immutable passive or active health result. +type HealthStatus struct { + CheckedAt time.Time + State LifecycleState + Connected bool + Ready bool + Degraded bool + LastConnectedAt time.Time + LastReadyAt time.Time + Error *SafeError + Capabilities map[string]bool +} + +// DiagnosticSnapshot is a bounded point-in-time broker diagnostic view. +type DiagnosticSnapshot struct { + BrokerSystem string + InstanceID string + Health HealthStatus + ReconnectCount uint64 + SubscriptionCount int64 + InFlight map[Operation]int64 + OperationTotals map[string]uint64 + LastFailure *SafeError + DroppedRecords uint64 + UnsupportedCapabilities []string +} + +// Severity identifies the importance of an event. +type Severity string + +const ( + SeverityDebug Severity = "debug" + SeverityInfo Severity = "info" + SeverityWarn Severity = "warn" + SeverityError Severity = "error" +) + +// BrokerStateEvent is a safe, ordered lifecycle or failure notification. +type BrokerStateEvent struct { + Sequence uint64 + Timestamp time.Time + BrokerSystem string + InstanceID string + Kind string + From LifecycleState + To LifecycleState + Operation Operation + Severity Severity + Outcome Outcome + Error *SafeError + Attributes map[string]string +} + +// EventSink consumes structured events outside broker operation goroutines. +type EventSink interface { + HandleObservabilityEvent(context.Context, BrokerStateEvent) error +} + +// EventSinkFunc adapts a function to EventSink. +type EventSinkFunc func(context.Context, BrokerStateEvent) error + +func (f EventSinkFunc) HandleObservabilityEvent(ctx context.Context, event BrokerStateEvent) error { + return f(ctx, event) +} + +// OverflowPolicy determines which event is discarded when a buffer is full. +type OverflowPolicy string + +const ( + OverflowDropNewest OverflowPolicy = "drop-newest" + OverflowDropOldest OverflowPolicy = "drop-oldest" +) + +// CorrelationConflictPolicy controls reserved metadata conflicts. +type CorrelationConflictPolicy string + +const ( + CorrelationPreserve CorrelationConflictPolicy = "preserve" + CorrelationReplace CorrelationConflictPolicy = "replace" +) + +// ObservabilityConfig is immutable after Runtime construction except for the +// category mask changed through SetCategories. +type ObservabilityConfig struct { + Categories CategorySet + InstanceID string + Sink EventSink + BufferCapacity int + OverflowPolicy OverflowPolicy + SinkTimeout time.Duration + ProbeTimeout time.Duration + RedactedFields []string + CorrelationConflict CorrelationConflictPolicy +} + +// ObservabilityOption configures optional observability. +type ObservabilityOption func(*ObservabilityConfig) + +func defaultObservabilityConfig() ObservabilityConfig { + return ObservabilityConfig{BufferCapacity: 256, OverflowPolicy: OverflowDropNewest, SinkTimeout: time.Second, ProbeTimeout: 5 * time.Second, CorrelationConflict: CorrelationPreserve} +} + +// EnableCategories enables only the supplied categories in addition to any already enabled. +func EnableCategories(categories ...Category) ObservabilityOption { + return func(config *ObservabilityConfig) { config.Categories |= Categories(categories...) } +} + +func WithEventSink(sink EventSink) ObservabilityOption { + return func(config *ObservabilityConfig) { config.Sink = sink } +} +func WithEventBuffer(capacity int, policy OverflowPolicy) ObservabilityOption { + return func(config *ObservabilityConfig) { config.BufferCapacity, config.OverflowPolicy = capacity, policy } +} +func WithSinkTimeout(timeout time.Duration) ObservabilityOption { + return func(config *ObservabilityConfig) { config.SinkTimeout = timeout } +} +func WithProbeTimeout(timeout time.Duration) ObservabilityOption { + return func(config *ObservabilityConfig) { config.ProbeTimeout = timeout } +} +func WithRedactedFields(names ...string) ObservabilityOption { + return func(config *ObservabilityConfig) { config.RedactedFields = append(config.RedactedFields, names...) } +} +func WithCorrelationConflictPolicy(policy CorrelationConflictPolicy) ObservabilityOption { + return func(config *ObservabilityConfig) { config.CorrelationConflict = policy } +} +func WithInstanceID(id string) ObservabilityOption { + return func(config *ObservabilityConfig) { config.InstanceID = id } +} + +func validateObservabilityConfig(config ObservabilityConfig) error { + if config.Categories&^allCategories != 0 { + return fmt.Errorf("%w: unknown category bits", ErrInvalidObservabilityConfig) + } + if config.BufferCapacity < 0 || config.BufferCapacity > 1_000_000 { + return fmt.Errorf("%w: buffer capacity out of range", ErrInvalidObservabilityConfig) + } + if config.SinkTimeout <= 0 || config.ProbeTimeout <= 0 { + return fmt.Errorf("%w: timeouts must be positive", ErrInvalidObservabilityConfig) + } + if config.OverflowPolicy != OverflowDropNewest && config.OverflowPolicy != OverflowDropOldest { + return fmt.Errorf("%w: unknown overflow policy", ErrInvalidObservabilityConfig) + } + if config.CorrelationConflict != CorrelationPreserve && config.CorrelationConflict != CorrelationReplace { + return fmt.Errorf("%w: unknown correlation conflict policy", ErrInvalidObservabilityConfig) + } + if (config.Categories.Has(CategoryLogging) || config.Categories.Has(CategoryStateEvents)) && config.Sink == nil { + return fmt.Errorf("%w: event sink required for logging or state events", ErrInvalidObservabilityConfig) + } + return nil +} diff --git a/observability_benchmark_test.go b/observability_benchmark_test.go new file mode 100644 index 0000000..1167ebc --- /dev/null +++ b/observability_benchmark_test.go @@ -0,0 +1,93 @@ +//go:build !race + +package broker + +import ( + "context" + "crypto/sha256" + "testing" + "time" +) + +var representativeDigest [32]byte + +func BenchmarkObservabilityDisabledPublish(b *testing.B) { + broker := NewNoopBroker() + if err := broker.Connect(); err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = broker.Disconnect() }) + message := &Message{} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := broker.Publish(context.Background(), "unused", message); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkObservabilityDiagnosticsPublish(b *testing.B) { + broker := NewNoopBroker(WithObservability(EnableCategories(CategoryDiagnostics))) + if err := broker.Connect(); err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = broker.Disconnect() }) + message := &Message{} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := broker.Publish(context.Background(), "unused", message); err != nil { + b.Fatal(err) + } + } +} + +func runRepresentativePublish(iterations int, enabled bool) time.Duration { + var options []Option + if enabled { + options = append(options, WithObservability(EnableCategories(CategoryDiagnostics, CategoryMeasurements, CategoryCorrelation))) + } + candidate := NewNoopBroker(options...) + _ = candidate.Connect() + defer candidate.Disconnect() + payload := make([]byte, 64*1024) + message := &Message{Body: payload} + started := time.Now() + for index := 0; index < iterations; index++ { + representativeDigest = sha256.Sum256(payload) + _ = candidate.Publish(context.Background(), "representative", message) + } + return time.Since(started) +} + +func TestObservabilityStandardOverheadBudget(t *testing.T) { + const iterations = 2000 + // Warm caches before comparing alternating runs. + runRepresentativePublish(100, false) + runRepresentativePublish(100, true) + var disabled, enabled time.Duration + for trial := 0; trial < 3; trial++ { + disabled += runRepresentativePublish(iterations, false) + enabled += runRepresentativePublish(iterations, true) + } + overhead := float64(enabled-disabled) / float64(disabled) + if overhead >= 0.05 { + t.Fatalf("standard observability overhead %.2f%% exceeds 5%% (disabled=%s enabled=%s)", overhead*100, disabled, enabled) + } + t.Logf("standard observability overhead %.2f%% (disabled=%s enabled=%s)", overhead*100, disabled, enabled) +} + +func BenchmarkObservabilityStandardOverhead(b *testing.B) { + for _, enabled := range []bool{false, true} { + name := "disabled" + if enabled { + name = "standard-enabled" + } + b.Run(name, func(b *testing.B) { + for index := 0; index < b.N; index++ { + runRepresentativePublish(1, enabled) + } + }) + } +} diff --git a/observability_context.go b/observability_context.go new file mode 100644 index 0000000..ccc5679 --- /dev/null +++ b/observability_context.go @@ -0,0 +1,61 @@ +package broker + +import ( + "context" + "strings" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" +) + +const ( + TraceParentHeader = "traceparent" + TraceStateHeader = "tracestate" +) + +// InjectCorrelation copies reserved fields according to the configured policy. +func InjectCorrelation(headers map[string]string, traceParent, traceState string, policy CorrelationConflictPolicy) map[string]string { + result := make(map[string]string, len(headers)+2) + for key, value := range headers { + result[key] = value + } + set := func(key, value string) { + if value == "" { + return + } + if _, exists := result[key]; !exists || policy == CorrelationReplace { + result[key] = value + } + } + set(TraceParentHeader, strings.TrimSpace(traceParent)) + set(TraceStateHeader, strings.TrimSpace(traceState)) + return result +} + +// InjectContext returns copied headers with the configured OpenTelemetry +// propagation fields. The caller's map is never mutated. +func (r *Runtime) InjectContext(ctx context.Context, headers map[string]string) map[string]string { + result := make(map[string]string, len(headers)+2) + for key, value := range headers { + result[key] = value + } + if r == nil || !r.Enabled(CategoryCorrelation) { + return result + } + temporary := propagation.MapCarrier{} + otel.GetTextMapPropagator().Inject(ctx, temporary) + for key, value := range temporary { + if _, exists := result[key]; !exists || r.config.CorrelationConflict == CorrelationReplace { + result[key] = value + } + } + return result +} + +// ExtractContext returns a context linked to valid correlation headers. +func (r *Runtime) ExtractContext(ctx context.Context, headers map[string]string) context.Context { + if r == nil || !r.Enabled(CategoryCorrelation) { + return ctx + } + return otel.GetTextMapPropagator().Extract(ctx, propagation.MapCarrier(headers)) +} diff --git a/observability_context_test.go b/observability_context_test.go new file mode 100644 index 0000000..653f5c0 --- /dev/null +++ b/observability_context_test.go @@ -0,0 +1,49 @@ +package broker + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" +) + +func TestCorrelationInjectExtractAndPreserve(t *testing.T) { + previous := otel.GetTextMapPropagator() + otel.SetTextMapPropagator(propagation.TraceContext{}) + t.Cleanup(func() { otel.SetTextMapPropagator(previous) }) + runtime, err := NewRuntime("test", &ObservabilityConfig{Categories: Categories(CategoryCorrelation), BufferCapacity: 1, OverflowPolicy: OverflowDropNewest, SinkTimeout: 1, ProbeTimeout: 1, CorrelationConflict: CorrelationPreserve}) + if err != nil { + t.Fatal(err) + } + spanContext := trace.NewSpanContext(trace.SpanContextConfig{TraceID: trace.TraceID{1}, SpanID: trace.SpanID{2}, TraceFlags: trace.FlagsSampled}) + ctx := trace.ContextWithSpanContext(context.Background(), spanContext) + original := map[string]string{TraceParentHeader: "user-value", "business": "safe"} + injected := runtime.InjectContext(ctx, original) + if injected[TraceParentHeader] != "user-value" { + t.Fatal("preserve policy overwrote user metadata") + } + if original["business"] != "safe" || len(original) != 2 { + t.Fatal("injection mutated caller headers") + } + runtime.config.CorrelationConflict = CorrelationReplace + replaced := runtime.InjectContext(ctx, original) + if replaced[TraceParentHeader] == "user-value" { + t.Fatal("replace policy did not inject context") + } + extracted := runtime.ExtractContext(context.Background(), replaced) + if trace.SpanContextFromContext(extracted).TraceID() != spanContext.TraceID() { + t.Fatal("trace ID did not round trip") + } +} + +func TestInjectCorrelationHelper(t *testing.T) { + original := map[string]string{TraceParentHeader: "existing"} + if got := InjectCorrelation(original, "new", "state", CorrelationPreserve); got[TraceParentHeader] != "existing" || got[TraceStateHeader] != "state" { + t.Fatalf("unexpected preserve result: %v", got) + } + if got := InjectCorrelation(original, "new", "", CorrelationReplace); got[TraceParentHeader] != "new" { + t.Fatalf("unexpected replace result: %v", got) + } +} diff --git a/observability_event_test.go b/observability_event_test.go new file mode 100644 index 0000000..1810fd8 --- /dev/null +++ b/observability_event_test.go @@ -0,0 +1,71 @@ +package broker + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestEventOrderingAndDynamicToggle(t *testing.T) { + sink := newRecordingSink() + runtime, _ := NewRuntime("test", &ObservabilityConfig{Sink: sink, BufferCapacity: 8, OverflowPolicy: OverflowDropNewest, SinkTimeout: time.Second, ProbeTimeout: time.Second, CorrelationConflict: CorrelationPreserve}) + if sink.count() != 0 { + t.Fatal("disabled runtime emitted") + } + if err := runtime.SetCategories(Categories(CategoryStateEvents)); err != nil { + t.Fatal(err) + } + runtime.Transition(StateConnecting, false, false, nil) + runtime.Transition(StateReady, true, true, nil) + runtime.Close() + sink.mu.Lock() + events := append([]BrokerStateEvent(nil), sink.events...) + sink.mu.Unlock() + if len(events) != 2 || events[0].Sequence >= events[1].Sequence { + t.Fatalf("events=%+v", events) + } + events[0].Attributes = map[string]string{"changed": "yes"} + if runtime.Snapshot().Health.State != StateReady { + t.Fatal("event mutation changed runtime") + } +} + +func TestEventSinkFailurePanicAndTimeout(t *testing.T) { + tests := []struct { + name string + sink EventSink + }{ + {"error", EventSinkFunc(func(context.Context, BrokerStateEvent) error { return errors.New("sink") })}, + {"panic", EventSinkFunc(func(context.Context, BrokerStateEvent) error { panic("sink") })}, + {"timeout", EventSinkFunc(func(ctx context.Context, _ BrokerStateEvent) error { <-ctx.Done(); return ctx.Err() })}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + runtime, _ := NewRuntime("test", &ObservabilityConfig{Categories: Categories(CategoryStateEvents), Sink: tc.sink, BufferCapacity: 2, OverflowPolicy: OverflowDropNewest, SinkTimeout: time.Millisecond, ProbeTimeout: time.Second, CorrelationConflict: CorrelationPreserve}) + runtime.Transition(StateReady, true, true, nil) + time.Sleep(5 * time.Millisecond) + runtime.Close() + if runtime.Snapshot().DroppedRecords == 0 { + t.Fatal("sink failure was not counted") + } + }) + } +} + +func TestEventOverflowPoliciesBoundCapacity(t *testing.T) { + for _, policy := range []OverflowPolicy{OverflowDropNewest, OverflowDropOldest} { + t.Run(string(policy), func(t *testing.T) { + block := make(chan struct{}) + runtime, _ := NewRuntime("test", &ObservabilityConfig{Categories: Categories(CategoryStateEvents), Sink: EventSinkFunc(func(context.Context, BrokerStateEvent) error { <-block; return nil }), BufferCapacity: 2, OverflowPolicy: policy, SinkTimeout: time.Millisecond, ProbeTimeout: time.Second, CorrelationConflict: CorrelationPreserve}) + for i := 0; i < 1000; i++ { + runtime.Transition(StateReady, true, true, nil) + } + if runtime.Snapshot().DroppedRecords == 0 { + t.Fatal("failure storm did not overflow bounded queue") + } + runtime.Close() + close(block) + }) + } +} diff --git a/observability_failure_test.go b/observability_failure_test.go new file mode 100644 index 0000000..8779278 --- /dev/null +++ b/observability_failure_test.go @@ -0,0 +1,51 @@ +package broker + +import ( + "errors" + "strings" + "testing" + "time" +) + +func TestFailureRecordsCoverRequiredOperations(t *testing.T) { + operations := []Operation{OperationConnect, OperationPublish, OperationReceive, OperationHandler, OperationAck, OperationNack, OperationRetry, OperationReconnect, OperationUnsubscribe, OperationDisconnect} + for _, operation := range operations { + t.Run(string(operation), func(t *testing.T) { + runtime, err := NewRuntime("test", &ObservabilityConfig{Categories: Categories(CategoryDiagnostics), BufferCapacity: 2, OverflowPolicy: OverflowDropNewest, SinkTimeout: time.Second, ProbeTimeout: time.Second, CorrelationConflict: CorrelationPreserve}) + if err != nil { + t.Fatal(err) + } + started := runtime.StartOperation(operation) + runtime.Observe(operation, started, errors.New("password=secret failure")) + snapshot := runtime.Snapshot() + if snapshot.OperationTotals[string(operation)+":failure"] != 1 { + t.Fatalf("missing total: %v", snapshot.OperationTotals) + } + if snapshot.LastFailure == nil || snapshot.LastFailure.Category == "" { + t.Fatal("missing safe failure") + } + if strings.Contains(snapshot.LastFailure.Summary, "secret") || len(snapshot.LastFailure.Summary) > 515 { + t.Fatalf("unsafe summary: %q", snapshot.LastFailure.Summary) + } + snapshot.LastFailure.Summary = "mutated" + if runtime.Snapshot().LastFailure.Summary == "mutated" { + t.Fatal("snapshot failure was mutable") + } + }) + } +} + +func TestFailureEventAvailablePromptly(t *testing.T) { + sink := newRecordingSink() + runtime, err := NewRuntime("test", &ObservabilityConfig{Categories: Categories(CategoryLogging), Sink: sink, BufferCapacity: 4, OverflowPolicy: OverflowDropNewest, SinkTimeout: time.Second, ProbeTimeout: time.Second, CorrelationConflict: CorrelationPreserve}) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + runtime.Observe(OperationPublish, time.Now(), errors.New("publish failed")) + select { + case <-sink.wake: + case <-time.After(time.Second): + t.Fatal("failure event exceeded one-second availability target") + } +} diff --git a/observability_health_test.go b/observability_health_test.go new file mode 100644 index 0000000..9e344f7 --- /dev/null +++ b/observability_health_test.go @@ -0,0 +1,57 @@ +package broker + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +func TestHealthLifecycleAndReadiness(t *testing.T) { + runtime, err := NewRuntime("test", nil) + if err != nil { + t.Fatal(err) + } + checks := []struct { + state LifecycleState + connected, ready bool + }{{StateConnecting, false, false}, {StateDegraded, true, false}, {StateReady, true, true}, {StateReconnecting, false, false}, {StateStopped, false, false}} + for _, check := range checks { + runtime.Transition(check.state, check.connected, check.ready, nil) + health := runtime.Health() + if health.State != check.state || health.Connected != check.connected || health.Ready != check.ready { + t.Fatalf("health = %+v", health) + } + } +} + +func TestConcurrentHealthAndTransitions(t *testing.T) { + runtime, _ := NewRuntime("test", nil) + var group sync.WaitGroup + for i := 0; i < 16; i++ { + group.Add(1) + go func(index int) { + defer group.Done() + for j := 0; j < 1000; j++ { + if index%2 == 0 { + runtime.Transition(StateReady, true, true, nil) + } else { + _ = runtime.Health() + _ = runtime.Snapshot() + } + } + }(i) + } + group.Wait() +} + +func TestProbeTimeoutAndDegradedResult(t *testing.T) { + runtime, _ := NewRuntime("test", &ObservabilityConfig{BufferCapacity: 1, OverflowPolicy: OverflowDropNewest, SinkTimeout: time.Second, ProbeTimeout: time.Millisecond, CorrelationConflict: CorrelationPreserve}) + runtime.Transition(StateDegraded, true, false, errors.New("partial")) + runtime.SetProbe(func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() }) + status, err := runtime.Probe(context.Background()) + if !errors.Is(err, context.DeadlineExceeded) || !status.Degraded || status.Ready { + t.Fatalf("status=%+v err=%v", status, err) + } +} diff --git a/observability_metrics_test.go b/observability_metrics_test.go new file mode 100644 index 0000000..70fe8ae --- /dev/null +++ b/observability_metrics_test.go @@ -0,0 +1,42 @@ +package broker + +import ( + "errors" + "fmt" + "testing" + "time" +) + +func TestMetricCatalogSnapshotAccounting(t *testing.T) { + runtime, _ := NewRuntime("test", &ObservabilityConfig{Categories: Categories(CategoryDiagnostics, CategoryMeasurements), BufferCapacity: 1, OverflowPolicy: OverflowDropNewest, SinkTimeout: time.Second, ProbeTimeout: time.Second, CorrelationConflict: CorrelationPreserve}) + for _, operation := range []Operation{OperationPublish, OperationReceive, OperationHandler, OperationAck, OperationNack, OperationRetry, OperationReconnect} { + started := runtime.StartOperation(operation) + runtime.Observe(operation, started, nil) + started = runtime.StartOperation(operation) + runtime.Observe(operation, started, errors.New("failed")) + } + runtime.AddSubscription(1) + runtime.AddSubscription(-1) + snapshot := runtime.Snapshot() + for operation, value := range snapshot.InFlight { + if value != 0 { + t.Fatalf("inflight %s=%d", operation, value) + } + } + if len(snapshot.OperationTotals) != 14 { + t.Fatalf("totals=%v", snapshot.OperationTotals) + } +} + +func TestMetricCardinalityRemainsBounded(t *testing.T) { + runtime, _ := NewRuntime("test", &ObservabilityConfig{Categories: Categories(CategoryDiagnostics), BufferCapacity: 1, OverflowPolicy: OverflowDropNewest, SinkTimeout: time.Second, ProbeTimeout: time.Second, CorrelationConflict: CorrelationPreserve}) + for i := 0; i < 100_000; i++ { + _ = fmt.Sprintf("destination-%d", i) + started := runtime.StartOperation(OperationPublish) + runtime.Observe(OperationPublish, started, nil) + } + snapshot := runtime.Snapshot() + if len(snapshot.OperationTotals) != 1 || snapshot.OperationTotals["publish:success"] != 100_000 { + t.Fatalf("cardinality expanded: %v", snapshot.OperationTotals) + } +} diff --git a/observability_redact.go b/observability_redact.go new file mode 100644 index 0000000..7a6245d --- /dev/null +++ b/observability_redact.go @@ -0,0 +1,81 @@ +package broker + +import ( + "context" + "errors" + "regexp" + "strings" +) + +var sensitiveValuePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)(password|passwd|token|secret|api[_-]?key|credential|signature|sig|x-amz-signature)=([^&\s]+)`), + regexp.MustCompile(`(?i)(amqp|redis|nats|https?)://([^/@\s]+)@`), + regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`), +} + +func sanitizeSummary(value string) string { + for _, pattern := range sensitiveValuePatterns { + value = pattern.ReplaceAllString(value, "$1=[REDACTED]") + } + value = strings.TrimSpace(value) + if len(value) > 512 { + value = value[:512] + "…" + } + return value +} + +func safeError(operation Operation, err error) *SafeError { + if err == nil { + return nil + } + category := errorCategory(operation, err) + return &SafeError{Category: category, Summary: sanitizeSummary(err.Error())} +} + +func safeErrorWithFields(operation Operation, err error, fields []string) *SafeError { + safe := safeError(operation, err) + if safe == nil { + return nil + } + for _, field := range fields { + field = strings.TrimSpace(field) + if field == "" { + continue + } + pattern := regexp.MustCompile(`(?i)(` + regexp.QuoteMeta(field) + `)=([^&\s]+)`) + safe.Summary = pattern.ReplaceAllString(safe.Summary, "$1=[REDACTED]") + } + return safe +} + +func errorCategory(operation Operation, err error) ErrorCategory { + if errors.Is(err, context.Canceled) { + return ErrorCategoryCanceled + } + if errors.Is(err, context.DeadlineExceeded) { + return ErrorCategoryTimeout + } + if errors.Is(err, ErrUnsupported) { + return ErrorCategoryUnsupported + } + switch operation { + case OperationConnect: + return ErrorCategoryConnection + case OperationPublish: + return ErrorCategoryPublish + case OperationReceive: + return ErrorCategoryConsume + case OperationHandler: + return ErrorCategoryHandler + case OperationAck: + return ErrorCategoryAck + case OperationNack: + return ErrorCategoryNack + case OperationReconnect: + return ErrorCategoryReconnect + case OperationDisconnect: + return ErrorCategoryShutdown + default: + return ErrorCategoryUnknown + } +} diff --git a/observability_redact_test.go b/observability_redact_test.go new file mode 100644 index 0000000..fb65bed --- /dev/null +++ b/observability_redact_test.go @@ -0,0 +1,57 @@ +package broker + +import ( + "errors" + "fmt" + "strings" + "testing" + "time" +) + +func TestCustomRedactedFieldsExtendDefaults(t *testing.T) { + safe := safeErrorWithFields(OperationPublish, errors.New("password=default custom-secret=hidden visible=ok"), []string{"custom-secret"}) + if strings.Contains(safe.Summary, "default") || strings.Contains(safe.Summary, "hidden") { + t.Fatalf("secret leaked: %s", safe.Summary) + } + if !strings.Contains(safe.Summary, "visible=ok") { + t.Fatalf("safe context removed: %s", safe.Summary) + } +} + +func TestObservabilityOutputsExcludeSensitiveFixtures(t *testing.T) { + fixtures := []string{ + "amqp://admin:password@broker/vhost", + "https://service/path?X-Amz-Signature=abcdef&token=secret-token", + "api_key=private password=hunter2", + "-----BEGIN PRIVATE KEY-----key-material-----END PRIVATE KEY-----", + } + for _, fixture := range fixtures { + summary := sanitizeSummary("vendor failure " + fixture) + for _, secret := range []string{"admin:password", "abcdef", "secret-token", "private", "hunter2", "key-material"} { + if strings.Contains(summary, secret) { + t.Fatalf("fixture %q leaked %q in %q", fixture, secret, summary) + } + } + } + runtime, _ := NewRuntime("test", &ObservabilityConfig{Categories: Categories(CategoryDiagnostics), BufferCapacity: 1, OverflowPolicy: OverflowDropNewest, SinkTimeout: 1, ProbeTimeout: 1, RedactedFields: []string{"tenant-secret"}, CorrelationConflict: CorrelationPreserve}) + runtime.Observe(OperationPublish, time.Now(), errors.New("tenant-secret=hidden")) + snapshot := runtime.Snapshot() + if strings.Contains(snapshot.LastFailure.Summary, "hidden") { + t.Fatal("custom header value leaked") + } + if strings.Contains(fmt.Sprint(snapshot), "message-body-fixture") { + t.Fatal("message body appeared in snapshot") + } +} + +func FuzzObservabilityRedaction(f *testing.F) { + for _, seed := range []string{"password=hunter2", "token=abc", "https://user:pass@example.test", "-----BEGIN PRIVATE KEY-----secret-----END PRIVATE KEY-----"} { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, input string) { + result := sanitizeSummary(input) + if len(result) > 515 { + t.Fatalf("summary not bounded: %d", len(result)) + } + }) +} diff --git a/observability_runtime.go b/observability_runtime.go new file mode 100644 index 0000000..a6341cd --- /dev/null +++ b/observability_runtime.go @@ -0,0 +1,576 @@ +package broker + +import ( + "context" + "fmt" + "sort" + "sync" + "sync/atomic" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +type probeFunc func(context.Context) error + +// Runtime is the shared concurrency-safe observability implementation used by adapters. +type Runtime struct { + system string + instance string + categories atomic.Uint32 + config ObservabilityConfig + now func() time.Time + + mu sync.RWMutex + state LifecycleState + connected bool + ready bool + lastConnected time.Time + lastReady time.Time + lastFailure *SafeError + reconnects uint64 + subscriptions int64 + inflight map[Operation]int64 + totals map[string]uint64 + unsupported map[string]bool + probe probeFunc + sequence uint64 + dropped atomic.Uint64 + validationErr error + operationCounter metric.Int64Counter + durationHistogram metric.Float64Histogram + inflightCounter metric.Int64UpDownCounter + retryCounter metric.Int64Counter + reconnectCounter metric.Int64Counter + subscriptionGauge metric.Int64UpDownCounter + + dispatchMu sync.Mutex + queue chan BrokerStateEvent + stop chan struct{} + done chan struct{} +} + +// NewRuntimeForOptions creates an always-non-nil runtime for constructors that +// cannot return errors. ValidationError must be checked by Init or Connect. +func NewRuntimeForOptions(system string, options Options) *Runtime { + runtime, err := NewRuntime(system, options.Observability) + if err == nil { + runtime.setMeter(options.Meter) + runtime.SetUnsupported("active_probe") + return runtime + } + runtime, _ = NewRuntime(system, nil) + runtime.validationErr = err + return runtime +} + +func (r *Runtime) setMeter(meter metric.Meter) { + if meter == nil { + return + } + r.operationCounter, _ = meter.Int64Counter("broker.operations") + r.durationHistogram, _ = meter.Float64Histogram("broker.operation.duration", metric.WithUnit("s")) + r.inflightCounter, _ = meter.Int64UpDownCounter("broker.inflight") + r.retryCounter, _ = meter.Int64Counter("broker.retries") + r.reconnectCounter, _ = meter.Int64Counter("broker.reconnects") + r.subscriptionGauge, _ = meter.Int64UpDownCounter("broker.subscriptions") +} + +// ValidationError returns deferred constructor-time configuration errors. +func (r *Runtime) ValidationError() error { + if r == nil { + return nil + } + return r.validationErr +} + +// Observability returns this runtime as the optional capability handle. +func (r *Runtime) Observability() Observability { return r } + +// NewRuntime creates a disabled runtime when config is nil. +func NewRuntime(system string, config *ObservabilityConfig) (*Runtime, error) { + cfg := defaultObservabilityConfig() + if config != nil { + cfg = *config + } + if err := validateObservabilityConfig(cfg); err != nil { + return nil, err + } + instance := cfg.InstanceID + if instance == "" { + instance = fmt.Sprintf("%s-%x", system, time.Now().UnixNano()) + } + r := &Runtime{system: system, instance: instance, config: cfg, now: time.Now, state: StateUnknown, inflight: make(map[Operation]int64), totals: make(map[string]uint64), unsupported: make(map[string]bool)} + r.categories.Store(uint32(cfg.Categories)) + if r.sinkEnabled(cfg.Categories) { + r.startDispatcher() + } + return r, nil +} + +func (r *Runtime) sinkEnabled(set CategorySet) bool { + return r.config.Sink != nil && (set.Has(CategoryLogging) || set.Has(CategoryStateEvents)) +} + +// SetCategories changes signal enablement atomically. +func (r *Runtime) SetCategories(set CategorySet) error { + if set&^allCategories != 0 { + return fmt.Errorf("%w: unknown category bits", ErrInvalidObservabilityConfig) + } + if (set.Has(CategoryLogging) || set.Has(CategoryStateEvents)) && r.config.Sink == nil { + return fmt.Errorf("%w: event sink required", ErrInvalidObservabilityConfig) + } + old := CategorySet(r.categories.Swap(uint32(set))) + if !r.sinkEnabled(old) && r.sinkEnabled(set) { + r.startDispatcher() + } + if r.sinkEnabled(old) && !r.sinkEnabled(set) { + r.stopDispatcher() + } + return nil +} + +func (r *Runtime) enabled(category Category) bool { + return CategorySet(r.categories.Load()).Has(category) +} + +// Enabled reports whether one category is currently enabled. +func (r *Runtime) Enabled(category Category) bool { + return r != nil && r.enabled(category) +} + +// OperationsEnabled is a low-cost hot-path guard for operation diagnostics. +func (r *Runtime) OperationsEnabled() bool { + set := CategorySet(r.categories.Load()) + return set.Has(CategoryDiagnostics) || set.Has(CategoryMeasurements) || set.Has(CategoryLogging) +} + +// Health returns local state without network activity. +func (r *Runtime) Health() HealthStatus { + r.mu.RLock() + defer r.mu.RUnlock() + return r.healthLocked(r.now()) +} + +func (r *Runtime) healthLocked(now time.Time) HealthStatus { + return HealthStatus{CheckedAt: now.UTC(), State: r.state, Connected: r.connected, Ready: r.ready, Degraded: r.state == StateDegraded, LastConnectedAt: r.lastConnected, LastReadyAt: r.lastReady, Error: cloneSafeError(r.lastFailure), Capabilities: r.capabilitiesLocked()} +} + +func (r *Runtime) capabilitiesLocked() map[string]bool { + result := make(map[string]bool, len(r.unsupported)) + for key, unsupported := range r.unsupported { + result[key] = !unsupported + } + return result +} + +// Probe executes an explicitly registered adapter probe. +func (r *Runtime) Probe(ctx context.Context) (HealthStatus, error) { + r.mu.RLock() + probe := r.probe + r.mu.RUnlock() + if probe == nil { + return r.Health(), ErrUnsupported + } + if ctx == nil { + ctx = context.Background() + } + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, r.config.ProbeTimeout) + defer cancel() + } + err := probe(ctx) + status := r.Health() + status.CheckedAt = r.now().UTC() + if err != nil { + status.Error = safeError(OperationProbe, err) + } + return status, err +} + +// SetProbe registers an adapter-specific active probe. +func (r *Runtime) SetProbe(probe func(context.Context) error) { + if r == nil { + return + } + r.mu.Lock() + r.probe = probe + delete(r.unsupported, "active_probe") + r.mu.Unlock() +} + +// SetUnsupported declares a native limitation. +func (r *Runtime) SetUnsupported(capability string) { + if r == nil { + return + } + r.mu.Lock() + r.unsupported[capability] = true + r.mu.Unlock() +} + +// Transition updates lifecycle state and emits an optional state event. +func (r *Runtime) Transition(to LifecycleState, connected, ready bool, err error) { + if r == nil { + return + } + r.mu.Lock() + from := r.state + r.state, r.connected, r.ready = to, connected, ready + now := r.now().UTC() + if connected { + r.lastConnected = now + } + if to == StateReconnecting { + r.reconnects++ + } + if ready { + r.lastReady = now + } + if err != nil { + r.lastFailure = safeErrorWithFields(OperationConnect, err, r.config.RedactedFields) + } + r.sequence++ + event := BrokerStateEvent{Sequence: r.sequence, Timestamp: now, BrokerSystem: r.system, InstanceID: r.instance, Kind: "connection." + string(to), From: from, To: to, Operation: OperationConnect, Severity: SeverityInfo, Outcome: OutcomeSuccess, Error: cloneSafeError(r.lastFailure)} + if err != nil { + event.Severity, event.Outcome = SeverityError, OutcomeFailure + } + r.mu.Unlock() + if r.enabled(CategoryStateEvents) { + r.enqueue(event) + } +} + +// Observe records an operation completion and optional failure event. +func (r *Runtime) Observe(operation Operation, started time.Time, err error) { + if r == nil { + return + } + if !r.enabled(CategoryDiagnostics) && !r.enabled(CategoryMeasurements) && !(err != nil && r.enabled(CategoryLogging)) { + return + } + outcome := OutcomeSuccess + if err != nil { + outcome = OutcomeFailure + } + r.mu.Lock() + key := string(operation) + ":" + string(outcome) + r.totals[key]++ + if r.inflight[operation] > 0 { + r.inflight[operation]-- + } + if err != nil { + r.lastFailure = safeErrorWithFields(operation, err, r.config.RedactedFields) + r.sequence++ + } + event := BrokerStateEvent{Sequence: r.sequence, Timestamp: r.now().UTC(), BrokerSystem: r.system, InstanceID: r.instance, Kind: "operation.failed", To: r.state, Operation: operation, Severity: SeverityError, Outcome: outcome, Error: cloneSafeError(r.lastFailure)} + r.mu.Unlock() + if r.enabled(CategoryMeasurements) { + values := []attribute.KeyValue{attribute.String("messaging.system", r.system), attribute.String("messaging.operation", string(operation)), attribute.String("outcome", string(outcome))} + if err != nil && event.Error != nil { + values = append(values, attribute.String("error.category", string(event.Error.Category))) + } + attrs := metric.WithAttributes(values...) + if r.operationCounter != nil { + r.operationCounter.Add(context.Background(), 1, attrs) + } + if r.durationHistogram != nil && !started.IsZero() { + r.durationHistogram.Record(context.Background(), r.now().Sub(started).Seconds(), attrs) + } + if r.inflightCounter != nil { + r.inflightCounter.Add(context.Background(), -1, metric.WithAttributes(attribute.String("messaging.system", r.system), attribute.String("messaging.operation", string(operation)))) + } + if operation == OperationRetry && r.retryCounter != nil { + r.retryCounter.Add(context.Background(), 1, attrs) + } + if operation == OperationReconnect && r.reconnectCounter != nil { + r.reconnectCounter.Add(context.Background(), 1, attrs) + } + } + if err != nil && r.enabled(CategoryLogging) { + r.enqueue(event) + } +} + +// StartOperation increments an in-flight operation and returns its start time. +func (r *Runtime) StartOperation(operation Operation) time.Time { + if r == nil { + return time.Now() + } + if r.enabled(CategoryDiagnostics) || r.enabled(CategoryMeasurements) { + r.mu.Lock() + r.inflight[operation]++ + r.mu.Unlock() + } + if r.enabled(CategoryMeasurements) && r.inflightCounter != nil { + r.inflightCounter.Add(context.Background(), 1, metric.WithAttributes(attribute.String("messaging.system", r.system), attribute.String("messaging.operation", string(operation)))) + } + return r.now() +} + +// AddSubscription adjusts the active subscription count. +func (r *Runtime) AddSubscription(delta int64) { + if r == nil { + return + } + r.mu.Lock() + r.subscriptions += delta + if r.subscriptions < 0 { + r.subscriptions = 0 + } + r.mu.Unlock() + if r.enabled(CategoryMeasurements) && r.subscriptionGauge != nil { + r.subscriptionGauge.Add(context.Background(), delta, metric.WithAttributes(attribute.String("messaging.system", r.system))) + } +} + +// WrapHandler observes receive, handler, Ack, and Nack outcomes without +// changing the wrapped event's delivery decisions. +func (r *Runtime) WrapHandler(handler Handler) Handler { + if r == nil || handler == nil { + return handler + } + return func(ctx context.Context, event Event) (err error) { + instrument := r.OperationsEnabled() + if instrument { + received := r.StartOperation(OperationReceive) + r.Observe(OperationReceive, received, nil) + } + var started time.Time + if instrument { + started = r.StartOperation(OperationHandler) + } + if event != nil { + if message := event.Message(); message != nil { + ctx = r.ExtractContext(ctx, message.Header) + } + event = &observedEvent{Event: event, runtime: r} + } + err = handler(ctx, event) + if instrument { + r.Observe(OperationHandler, started, err) + } + return err + } +} + +// WrapSubscriber observes subscription lifetime and Unsubscribe outcomes. +func (r *Runtime) WrapSubscriber(subscriber Subscriber) Subscriber { + if r == nil || subscriber == nil { + return subscriber + } + r.AddSubscription(1) + return &observedSubscriber{Subscriber: subscriber, runtime: r} +} + +type observedSubscriber struct { + Subscriber + runtime *Runtime + once sync.Once +} + +func (s *observedSubscriber) Unsubscribe() (err error) { + instrument := s.runtime.OperationsEnabled() + var started time.Time + if instrument { + started = s.runtime.StartOperation(OperationUnsubscribe) + } + err = s.Subscriber.Unsubscribe() + if instrument { + s.runtime.Observe(OperationUnsubscribe, started, err) + } + if err == nil { + s.once.Do(func() { s.runtime.AddSubscription(-1) }) + } + return err +} + +type observedEvent struct { + Event + runtime *Runtime +} + +func (e *observedEvent) Ack() (err error) { + instrument := e.runtime.OperationsEnabled() + var started time.Time + if instrument { + started = e.runtime.StartOperation(OperationAck) + } + err = e.Event.Ack() + if instrument { + e.runtime.Observe(OperationAck, started, err) + } + return err +} + +func (e *observedEvent) Nack(requeue bool) (err error) { + instrument := e.runtime.OperationsEnabled() + var started time.Time + if instrument { + started = e.runtime.StartOperation(OperationNack) + } + err = e.Event.Nack(requeue) + if instrument { + e.runtime.Observe(OperationNack, started, err) + } + return err +} + +// Snapshot returns an immutable diagnostic copy. +func (r *Runtime) Snapshot() DiagnosticSnapshot { + r.mu.RLock() + defer r.mu.RUnlock() + unsupported := make([]string, 0, len(r.unsupported)) + for key, value := range r.unsupported { + if value { + unsupported = append(unsupported, key) + } + } + sort.Strings(unsupported) + return DiagnosticSnapshot{BrokerSystem: r.system, InstanceID: r.instance, Health: r.healthLocked(r.now()), ReconnectCount: r.reconnects, SubscriptionCount: r.subscriptions, InFlight: cloneOperationMap(r.inflight), OperationTotals: cloneStringMap(r.totals), LastFailure: cloneSafeError(r.lastFailure), DroppedRecords: r.dropped.Load(), UnsupportedCapabilities: unsupported} +} + +func cloneSafeError(value *SafeError) *SafeError { + if value == nil { + return nil + } + copy := *value + if value.Retryable != nil { + retryable := *value.Retryable + copy.Retryable = &retryable + } + return © +} +func cloneOperationMap(source map[Operation]int64) map[Operation]int64 { + result := make(map[Operation]int64, len(source)) + for key, value := range source { + result[key] = value + } + return result +} +func cloneStringMap(source map[string]uint64) map[string]uint64 { + result := make(map[string]uint64, len(source)) + for key, value := range source { + result[key] = value + } + return result +} + +func (r *Runtime) startDispatcher() { + r.dispatchMu.Lock() + defer r.dispatchMu.Unlock() + if r.queue != nil { + return + } + capacity := r.config.BufferCapacity + if capacity == 0 { + capacity = 256 + } + r.queue, r.stop, r.done = make(chan BrokerStateEvent, capacity), make(chan struct{}), make(chan struct{}) + go r.dispatch(r.queue, r.stop, r.done) +} + +// Start activates optional workers for the current category set. It is safe to +// call repeatedly when a broker reconnects. +func (r *Runtime) Start() { + if r == nil { + return + } + if r.sinkEnabled(CategorySet(r.categories.Load())) { + r.startDispatcher() + } +} + +func (r *Runtime) stopDispatcher() { + r.dispatchMu.Lock() + if r.stop == nil { + r.dispatchMu.Unlock() + return + } + stop, done := r.stop, r.done + r.stop, r.done, r.queue = nil, nil, nil + close(stop) + r.dispatchMu.Unlock() + select { + case <-done: + case <-time.After(r.config.SinkTimeout): + } +} + +func (r *Runtime) enqueue(event BrokerStateEvent) { + r.dispatchMu.Lock() + queue := r.queue + if queue == nil { + r.dispatchMu.Unlock() + return + } + select { + case queue <- event: + r.dispatchMu.Unlock() + return + default: + } + if r.config.OverflowPolicy == OverflowDropOldest { + select { + case <-queue: + default: + } + select { + case queue <- event: + default: + } + } + r.dropped.Add(1) + r.dispatchMu.Unlock() +} + +func (r *Runtime) dispatch(queue <-chan BrokerStateEvent, stop <-chan struct{}, done chan<- struct{}) { + defer close(done) + for { + select { + case <-stop: + for { + select { + case event := <-queue: + r.callSink(event) + default: + return + } + } + case event := <-queue: + r.callSink(event) + } + } +} + +func (r *Runtime) callSink(event BrokerStateEvent) { + ctx, cancel := context.WithTimeout(context.Background(), r.config.SinkTimeout) + defer cancel() + finished := make(chan struct{}) + go func() { + defer close(finished) + defer func() { + if recover() != nil { + r.dropped.Add(1) + } + }() + if err := r.config.Sink.HandleObservabilityEvent(ctx, event); err != nil { + r.dropped.Add(1) + } + }() + select { + case <-finished: + case <-ctx.Done(): + r.dropped.Add(1) + } +} + +// Close stops optional observability work within the configured budget. +func (r *Runtime) Close() { + if r == nil { + return + } + r.stopDispatcher() +} diff --git a/observability_test.go b/observability_test.go new file mode 100644 index 0000000..493c7ed --- /dev/null +++ b/observability_test.go @@ -0,0 +1,334 @@ +package broker + +import ( + "context" + "errors" + "fmt" + "runtime" + "sync" + "testing" + "time" + + "go.opentelemetry.io/otel/metric" + metricnoop "go.opentelemetry.io/otel/metric/noop" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" +) + +type controllableClock struct { + mu sync.Mutex + now time.Time +} + +func (c *controllableClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *controllableClock) Advance(delta time.Duration) { + c.mu.Lock() + c.now = c.now.Add(delta) + c.mu.Unlock() +} + +type fakeTelemetry struct { + meter metric.Meter + tracer trace.Tracer + recorder *tracetest.SpanRecorder +} + +func newFakeTelemetry() fakeTelemetry { + recorder := tracetest.NewSpanRecorder() + provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) + return fakeTelemetry{meter: metricnoop.NewMeterProvider().Meter("broker-test"), tracer: provider.Tracer("broker-test"), recorder: recorder} +} + +func assertNoGoroutineGrowth(t *testing.T, before, allowance int) { + t.Helper() + time.Sleep(20 * time.Millisecond) + if after := runtime.NumGoroutine(); after > before+allowance { + t.Fatalf("goroutines before=%d after=%d allowance=%d", before, after, allowance) + } +} + +type testEvent struct{ ackErr, nackErr error } + +func (e *testEvent) Topic() string { return "topic" } +func (e *testEvent) Message() *Message { return &Message{} } +func (e *testEvent) Ack() error { return e.ackErr } +func (e *testEvent) Nack(bool) error { return e.nackErr } +func (e *testEvent) Error() error { return nil } + +type recordingSink struct { + mu sync.Mutex + events []BrokerStateEvent + wake chan struct{} +} + +func newRecordingSink() *recordingSink { return &recordingSink{wake: make(chan struct{}, 32)} } + +func (s *recordingSink) HandleObservabilityEvent(_ context.Context, event BrokerStateEvent) error { + s.mu.Lock() + s.events = append(s.events, event) + s.mu.Unlock() + select { + case s.wake <- struct{}{}: + default: + } + return nil +} + +func (s *recordingSink) count() int { s.mu.Lock(); defer s.mu.Unlock(); return len(s.events) } + +func TestReusableObservabilityTestHarness(t *testing.T) { + before := runtime.NumGoroutine() + clock := &controllableClock{now: time.Date(2026, 8, 2, 1, 2, 3, 0, time.UTC)} + telemetry := newFakeTelemetry() + _, span := telemetry.tracer.Start(context.Background(), "test") + span.End() + if len(telemetry.recorder.Ended()) != 1 { + t.Fatal("fake tracer did not record span") + } + sink := newRecordingSink() + observability := &ObservabilityConfig{Categories: Categories(CategoryMeasurements, CategoryStateEvents), Sink: sink, BufferCapacity: 4, OverflowPolicy: OverflowDropNewest, SinkTimeout: time.Second, ProbeTimeout: time.Second, CorrelationConflict: CorrelationPreserve} + runtime := NewRuntimeForOptions("test", Options{Observability: observability, Meter: telemetry.meter}) + runtime.now = clock.Now + runtime.Transition(StateReady, true, true, nil) + if got := runtime.Health().CheckedAt; !got.Equal(clock.Now()) { + t.Fatalf("checked at %s, want %s", got, clock.Now()) + } + clock.Advance(time.Second) + started := runtime.StartOperation(OperationPublish) + clock.Advance(25 * time.Millisecond) + runtime.Observe(OperationPublish, started, nil) + runtime.Close() + assertNoGoroutineGrowth(t, before, 1) +} + +func TestObservabilityDisabledByDefault(t *testing.T) { + b := NewNoopBroker() + observable, ok := b.(Observable) + if !ok { + t.Fatal("noop broker must expose optional observability") + } + if got := observable.Observability().Snapshot(); got.BrokerSystem != "noop" || got.Health.State != StateUnknown { + t.Fatalf("unexpected snapshot: %+v", got) + } + if err := b.Connect(); err != nil { + t.Fatal(err) + } + if err := b.Publish(context.Background(), "topic", &Message{Body: []byte("secret body")}); err != nil { + t.Fatal(err) + } + if got := observable.Observability().Snapshot(); len(got.OperationTotals) != 0 { + t.Fatalf("disabled runtime recorded totals: %v", got.OperationTotals) + } + if err := b.Disconnect(); err != nil { + t.Fatal(err) + } +} + +func TestObservabilityCategoryIsolationAndToggle(t *testing.T) { + sink := newRecordingSink() + b := NewNoopBroker(WithObservability(WithEventSink(sink), EnableCategories(CategoryDiagnostics))) + obs := b.(Observable).Observability() + if err := b.Connect(); err != nil { + t.Fatal(err) + } + if err := b.Publish(context.Background(), "topic", &Message{}); err != nil { + t.Fatal(err) + } + if got := obs.Snapshot().OperationTotals["publish:success"]; got != 1 { + t.Fatalf("publish total = %d", got) + } + if sink.count() != 0 { + t.Fatal("diagnostics-only configuration emitted events") + } + if err := obs.SetCategories(Categories(CategoryStateEvents)); err != nil { + t.Fatal(err) + } + if err := b.Disconnect(); err != nil { + t.Fatal(err) + } + select { + case <-sink.wake: + case <-time.After(time.Second): + t.Fatal("state event not delivered") + } +} + +func TestEveryCategoryCanBeEnabledIndependently(t *testing.T) { + all := []Category{CategoryLogging, CategoryHealth, CategoryDiagnostics, CategoryMeasurements, CategoryCorrelation, CategoryStateEvents} + for _, category := range all { + t.Run(fmt.Sprint(category), func(t *testing.T) { + opts := []ObservabilityOption{EnableCategories(category)} + if category == CategoryLogging || category == CategoryStateEvents { + opts = append(opts, WithEventSink(newRecordingSink())) + } + candidate := NewNoopBroker(WithObservability(opts...)) + if err := candidate.Connect(); err != nil { + t.Fatal(err) + } + if err := candidate.Disconnect(); err != nil { + t.Fatal(err) + } + }) + } + sink := newRecordingSink() + runtime, _ := NewRuntime("test", &ObservabilityConfig{Sink: sink, BufferCapacity: 8, OverflowPolicy: OverflowDropNewest, SinkTimeout: time.Second, ProbeTimeout: time.Second, CorrelationConflict: CorrelationPreserve}) + var group sync.WaitGroup + for i := 0; i < 8; i++ { + group.Add(1) + go func(index int) { + defer group.Done() + for j := 0; j < 500; j++ { + if index%2 == 0 { + _ = runtime.SetCategories(Categories(CategoryDiagnostics)) + } else { + _ = runtime.SetCategories(Categories(CategoryStateEvents)) + } + _ = runtime.Snapshot() + } + }(i) + } + group.Wait() + runtime.Close() +} + +func TestObservabilitySnapshotsAreImmutable(t *testing.T) { + runtime, err := NewRuntime("test", nil) + if err != nil { + t.Fatal(err) + } + runtime.SetUnsupported("probe") + first := runtime.Snapshot() + first.Health.Capabilities["probe"] = true + first.InFlight[OperationPublish] = 99 + first.UnsupportedCapabilities[0] = "changed" + second := runtime.Snapshot() + if second.Health.Capabilities["probe"] || second.InFlight[OperationPublish] == 99 || second.UnsupportedCapabilities[0] != "probe" { + t.Fatal("snapshot exposed mutable runtime state") + } +} + +func TestObservabilityProbe(t *testing.T) { + runtime, err := NewRuntime("test", nil) + if err != nil { + t.Fatal(err) + } + if _, err := runtime.Probe(context.Background()); !errors.Is(err, ErrUnsupported) { + t.Fatalf("expected unsupported, got %v", err) + } + runtime.SetProbe(func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := runtime.Probe(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled, got %v", err) + } +} + +func TestObservabilityInvalidConfiguration(t *testing.T) { + b := NewNoopBroker(WithObservability(EnableCategories(CategoryLogging))) + if err := b.Connect(); !errors.Is(err, ErrInvalidObservabilityConfig) { + t.Fatalf("expected invalid config, got %v", err) + } +} + +func TestObservabilityOptionValidation(t *testing.T) { + sink := EventSinkFunc(func(context.Context, BrokerStateEvent) error { return nil }) + options := NewOptions(WithObservability(EnableCategories(CategoryLogging), WithEventSink(sink), WithEventBuffer(4, OverflowDropOldest), WithSinkTimeout(time.Second), WithProbeTimeout(time.Second), WithRedactedFields("custom"), WithCorrelationConflictPolicy(CorrelationReplace), WithInstanceID("instance"))) + runtime := NewRuntimeForOptions("test", *options) + if runtime.ValidationError() != nil { + t.Fatal(runtime.ValidationError()) + } + if runtime.Snapshot().InstanceID != "instance" { + t.Fatal("instance option not applied") + } + runtime.Close() + for _, cfg := range []*ObservabilityConfig{ + {BufferCapacity: -1, OverflowPolicy: OverflowDropNewest, SinkTimeout: time.Second, ProbeTimeout: time.Second, CorrelationConflict: CorrelationPreserve}, + {BufferCapacity: 1, OverflowPolicy: "bad", SinkTimeout: time.Second, ProbeTimeout: time.Second, CorrelationConflict: CorrelationPreserve}, + {BufferCapacity: 1, OverflowPolicy: OverflowDropNewest, SinkTimeout: 0, ProbeTimeout: time.Second, CorrelationConflict: CorrelationPreserve}, + {BufferCapacity: 1, OverflowPolicy: OverflowDropNewest, SinkTimeout: time.Second, ProbeTimeout: time.Second, CorrelationConflict: "bad"}, + } { + if _, err := NewRuntime("test", cfg); !errors.Is(err, ErrInvalidObservabilityConfig) { + t.Fatalf("expected invalid config for %+v, got %v", cfg, err) + } + } +} + +func TestRuntimeWrapHandlerAckNack(t *testing.T) { + runtime := NewRuntimeForOptions("test", Options{Observability: &ObservabilityConfig{Categories: Categories(CategoryDiagnostics), BufferCapacity: 1, OverflowPolicy: OverflowDropNewest, SinkTimeout: time.Second, ProbeTimeout: time.Second, CorrelationConflict: CorrelationPreserve}, Meter: metricnoop.NewMeterProvider().Meter("test")}) + wantAck, wantNack, wantHandler := errors.New("ack"), errors.New("nack"), errors.New("handler") + wrapped := runtime.WrapHandler(func(_ context.Context, event Event) error { + if !errors.Is(event.Ack(), wantAck) || !errors.Is(event.Nack(true), wantNack) { + t.Fatal("wrapped event changed errors") + } + return wantHandler + }) + if err := wrapped(context.Background(), &testEvent{ackErr: wantAck, nackErr: wantNack}); !errors.Is(err, wantHandler) { + t.Fatal(err) + } + snapshot := runtime.Snapshot() + for _, key := range []string{"ack:failure", "nack:failure", "handler:failure"} { + if snapshot.OperationTotals[key] != 1 { + t.Fatalf("%s = %d", key, snapshot.OperationTotals[key]) + } + } +} + +func TestRuntimeOverflowAndSinkFailure(t *testing.T) { + block := make(chan struct{}) + sink := EventSinkFunc(func(context.Context, BrokerStateEvent) error { <-block; return errors.New("sink") }) + runtime, err := NewRuntime("test", &ObservabilityConfig{Categories: Categories(CategoryStateEvents), Sink: sink, BufferCapacity: 1, OverflowPolicy: OverflowDropOldest, SinkTimeout: time.Millisecond, ProbeTimeout: time.Second, CorrelationConflict: CorrelationPreserve}) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 10; i++ { + runtime.Transition(StateReady, true, true, nil) + } + time.Sleep(10 * time.Millisecond) + if runtime.Snapshot().DroppedRecords == 0 { + t.Fatal("expected dropped records") + } + runtime.Close() + close(block) +} + +func TestSafeErrorRedaction(t *testing.T) { + err := errors.New("connect https://user:password@example.test?token=abc password=hunter2") + safe := safeError(OperationConnect, err) + for _, secret := range []string{"user:password", "token=abc", "hunter2"} { + if contains(safe.Summary, secret) { + t.Fatalf("safe summary leaked %q: %s", secret, safe.Summary) + } + } +} + +func contains(value, part string) bool { + for i := 0; i+len(part) <= len(value); i++ { + if value[i:i+len(part)] == part { + return true + } + } + return false +} + +type legacyThirdPartyBroker struct{} + +func (legacyThirdPartyBroker) Init(...Option) error { return nil } +func (legacyThirdPartyBroker) Options() Options { return Options{} } +func (legacyThirdPartyBroker) Address() string { return "" } +func (legacyThirdPartyBroker) Connect() error { return nil } +func (legacyThirdPartyBroker) Disconnect() error { return nil } +func (legacyThirdPartyBroker) Publish(context.Context, string, *Message, ...PublishOption) error { + return nil +} +func (legacyThirdPartyBroker) Subscribe(string, Handler, ...SubscribeOption) (Subscriber, error) { + return nil, nil +} +func (legacyThirdPartyBroker) String() string { return "third-party" } + +var _ Broker = legacyThirdPartyBroker{} diff --git a/options.go b/options.go index 8b1363e..2e05dc7 100644 --- a/options.go +++ b/options.go @@ -38,6 +38,10 @@ type Options struct { // ClientID is a unique identifier for the client. ClientID string + + // Observability configures optional diagnostics. Nil keeps every new + // observability category disabled. + Observability *ObservabilityConfig } // Logger is a simple logging interface. @@ -150,6 +154,20 @@ func WithLogger(l Logger) Option { } } +// WithObservability configures optional broker observability. Categories are +// still disabled unless explicitly enabled. +func WithObservability(opts ...ObservabilityOption) Option { + return func(o *Options) { + cfg := defaultObservabilityConfig() + for _, opt := range opts { + if opt != nil { + opt(&cfg) + } + } + o.Observability = &cfg + } +} + // DisableAutoAck will disable auto acking of messages // after they have been handled. func DisableAutoAck() SubscribeOption { diff --git a/specs/006-observability-diagnostics/baseline.md b/specs/006-observability-diagnostics/baseline.md new file mode 100644 index 0000000..ee71c28 --- /dev/null +++ b/specs/006-observability-diagnostics/baseline.md @@ -0,0 +1,21 @@ +# Implementation Baseline + +**Captured**: 2026-08-02 +**Environment**: Go 1.24.0, darwin/arm64, Apple M4 + +## Pre-feature gates + +- `go test -count=1 ./...`: pass +- `go test -race -count=1 ./...`: pass +- Existing `BenchmarkNoopBrokerPublish-10`: 26.90 ns/op, 80 B/op, 2 allocs/op + +Commands use an isolated writable `GOCACHE` in the Codex sandbox. Results are a same-machine +reference only; performance success criteria require repeated statistical comparison after the +implementation is complete. + +## Intentional capability limits + +All adapters provide passive local health. Redis implements `PING`, RabbitMQ checks its connection +state, Kafka performs a cancelable TCP dial, and NATS uses `FlushWithContext`. RocketMQ, SQS, and +GCP Pub/Sub return `broker.ErrUnsupported` because their adapter abstractions do not expose a +low-impact cancelable probe. These limits are also listed in `ADAPTER_EXTENSIONS.md`. diff --git a/specs/006-observability-diagnostics/checklists/requirements.md b/specs/006-observability-diagnostics/checklists/requirements.md new file mode 100644 index 0000000..669bb26 --- /dev/null +++ b/specs/006-observability-diagnostics/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Opt-in Observability and Diagnostics + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-02 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No `[NEEDS CLARIFICATION]` markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation passed on the first review iteration. +- The specification intentionally leaves exporter, storage, and dashboard choices to users. diff --git a/specs/006-observability-diagnostics/contracts/example.md b/specs/006-observability-diagnostics/contracts/example.md new file mode 100644 index 0000000..91547dc --- /dev/null +++ b/specs/006-observability-diagnostics/contracts/example.md @@ -0,0 +1,71 @@ +# Observability Example Contract + +The implementation must add a runnable `examples/observability` program. It uses the in-memory +noop broker by default so users can explore the feature without Docker or credentials. The example +is instructional production-style code: it checks errors, uses bounded contexts, cleans up the +subscriber and broker, and never embeds secrets. + +## Command-line switches + +| Switch | Default | Behavior | +|---|---:|---| +| `-observe` | `false` | Master switch; without it all new observability remains disabled | +| `-all` | `false` | Enables every category; requires `-observe` | +| `-logs` | `false` | Enables structured failure logging | +| `-health` | `false` | Prints passive health before connect, after connect, and after disconnect | +| `-diagnostics` | `false` | Prints a safe diagnostic snapshot at scenario completion | +| `-metrics` | `false` | Collects and prints bounded operation measurements through an example exporter | +| `-correlation` | `false` | Injects and verifies correlation context without printing trace identifiers by default | +| `-events` | `false` | Prints lifecycle and failure state events through a bounded sink | +| `-probe` | `false` | Runs an explicitly timed active probe; requires `-health` | +| `-scenario` | `normal` | Selects `normal`, `connect-failure`, `publish-failure`, or `handler-failure` | +| `-timeout` | `5s` | Bounds connect, publish, handler completion, and active probe waiting | + +Category switches supplied without `-observe`, `-probe` without `-health`, unknown scenarios, and +non-positive timeouts produce a concise usage error and non-zero exit. `-all` may be combined with +individual switches idempotently. + +## Scenarios + +### Normal + +Connect, subscribe, publish one non-sensitive demonstration message, await handler completion, +unsubscribe, and disconnect. With observation disabled it prints only the example's normal status. +With categories enabled it demonstrates their respective output. + +### Connection failure + +Use an example-local broker test double or supported deterministic noop failure option to return a +categorized connection error containing a secret fixture. The visible log/event/snapshot must show +the adapter, operation, state, and safe category, while the secret fixture is absent. + +### Publish failure + +Connect successfully, then trigger a deterministic publication error. Display the categorized +failure and unchanged broker readiness. No retry is introduced by the example. + +### Handler failure + +Publish successfully and return a deterministic handler error. Demonstrate handler failure +measurement/event reporting and verify that acknowledgment behavior follows the configured broker +semantics rather than an observability decision. + +## Output rules + +- Use stable prefixes (`health`, `event`, `metric`, `snapshot`, `scenario`) so smoke tests can parse + output without depending on timestamps. +- Never print message bodies, raw connection strings, reserved correlation values, or raw vendor + objects as diagnostic output. +- Sort maps before printing so examples and tests are deterministic. +- Clearly label unsupported capabilities and errors matched with `errors.Is`. +- Human-readable output is for teaching and testing only; the library contract remains typed Go + values rather than this text format. + +## Required tests + +- `go build ./examples/observability` succeeds. +- A table-driven test covers master switch off, each category alone, `-all`, invalid combinations, + every scenario, and timeout cancellation. +- Output assertions verify category isolation and absence of known secret/body fixtures. +- The disabled invocation terminates with no dispatcher worker left running. +- README commands are executed or compile-checked in CI to prevent drift. diff --git a/specs/006-observability-diagnostics/contracts/public-api.md b/specs/006-observability-diagnostics/contracts/public-api.md new file mode 100644 index 0000000..eef3961 --- /dev/null +++ b/specs/006-observability-diagnostics/contracts/public-api.md @@ -0,0 +1,99 @@ +# Public API Contract + +This contract describes the intended vendor-neutral Go surface. Exact comments may be refined +during implementation, but compatibility and behavior requirements are normative. + +## Capability discovery + +The existing `broker.Broker` interface remains unchanged. An adapter may implement: + +```go +type Observable interface { + Observability() Observability +} + +type Observability interface { + HealthChecker + DiagnosticsProvider + SetCategories(CategorySet) error +} + +type HealthChecker interface { + Health() HealthStatus + Probe(context.Context) (HealthStatus, error) +} + +type DiagnosticsProvider interface { + Snapshot() DiagnosticSnapshot +} +``` + +Callers use a checked type assertion. Failure to implement `Observable` means the broker does not +support the new capability; it is not an error for existing third-party brokers. + +`Health`, `Snapshot`, and returned nested values are copies. They are safe for concurrent use with +all broker methods. `Probe` returns `ErrUnsupported` when no meaningful native probe exists and +must return promptly when its context is canceled. + +## Configuration + +```go +func WithObservability(opts ...ObservabilityOption) Option + +func EnableCategories(categories ...Category) ObservabilityOption +func WithEventSink(EventSink) ObservabilityOption +func WithEventBuffer(capacity int, policy OverflowPolicy) ObservabilityOption +func WithSinkTimeout(time.Duration) ObservabilityOption +func WithProbeTimeout(time.Duration) ObservabilityOption +func WithRedactedFields(names ...string) ObservabilityOption +func WithCorrelationConflictPolicy(CorrelationConflictPolicy) ObservabilityOption +func WithInstanceID(string) ObservabilityOption +``` + +All categories are disabled when `WithObservability` is absent or no category is enabled. A sink +does not implicitly enable a category. Invalid capacity, duration, instance ID, or nil required +dependency returns a configuration error from `Init`/`Connect` according to existing constructor +behavior; it never silently falls back to an unsafe value. + +`SetCategories` changes only enablement. It is atomic and safe during broker operations. Enabling a +sink-backed category starts at most one bounded dispatcher; disabling the final sink-backed +category stops it without blocking broker work. Configuration values other than categories require +normal re-initialization. + +Existing `WithLogger`, `Tracer`, and `Meter` remain supported. They do not become globally enabled +by this feature. The implementation documents their mapping to the new category model and avoids +double emission. + +## Structured sink + +```go +type EventSink interface { + HandleObservabilityEvent(context.Context, BrokerStateEvent) error +} +``` + +The sink is called outside broker operation and acknowledgment goroutines. A callback error, panic, +or timeout increments `DroppedRecords` and may generate a coalesced internal warning, but cannot be +recursively delivered without bound. Event delivery is at-most-once and best-effort. Per-instance +sequence numbers expose gaps. + +## Errors and unsupported behavior + +The package exports errors inspectable with `errors.Is`: + +```go +var ErrUnsupported = errors.New("broker: observability capability unsupported") +var ErrInvalidObservabilityConfig = errors.New("broker: invalid observability configuration") +``` + +An adapter that exposes `Observable` must enumerate unsupported sub-capabilities in snapshots. +Requests for unsupported active behavior return `ErrUnsupported`; passive queries still return the +best safe local information. No observability method panics because a category is disabled. + +## Compatibility guarantees + +- Existing `Broker`, `Event`, `Subscriber`, `Logger`, and option signatures do not change. +- Disabled mode emits no new output, starts no worker, and performs no observability network call. +- Observability cannot acknowledge, negatively acknowledge, retry, drop, or mutate a user message. +- The root API contains no vendor SDK type. +- User-provided metadata is not overwritten unless `replace` conflict policy is explicitly chosen. diff --git a/specs/006-observability-diagnostics/contracts/signal-catalog.md b/specs/006-observability-diagnostics/contracts/signal-catalog.md new file mode 100644 index 0000000..2812dd6 --- /dev/null +++ b/specs/006-observability-diagnostics/contracts/signal-catalog.md @@ -0,0 +1,65 @@ +# Signal Catalog Contract + +Signal names and bounded attributes are consistent across adapters. Unsupported native detail is +omitted and declared in the diagnostic snapshot rather than represented with fabricated values. + +## Operations + +`connect`, `disconnect`, `publish`, `receive`, `handler`, `ack`, `nack`, `retry`, `reconnect`, +`subscribe`, `unsubscribe`, `probe`, `dispatch`. + +## Metrics + +| Name | Kind | Unit | Meaning | +|---|---|---|---| +| `broker.operations` | Counter | `{operation}` | Completed operations by outcome | +| `broker.operation.duration` | Histogram | `s` | Operation elapsed time using monotonic timing | +| `broker.inflight` | Up/down counter | `{operation}` | Currently executing publish/receive/handler/Ack/Nack work | +| `broker.retries` | Counter | `{retry}` | Adapter-observed retries | +| `broker.reconnects` | Counter | `{reconnect}` | Reconnect attempts/outcomes | +| `broker.subscriptions` | Up/down counter | `{subscription}` | Current active subscriptions | +| `broker.observability.dropped` | Counter | `{record}` | Records dropped due to overflow, timeout, sink error, or panic | + +Allowed attributes: + +- `messaging.system`: fixed adapter name. +- `messaging.operation`: one operation from this catalog. +- `outcome`: `success`, `failure`, `canceled`, or `unsupported`. +- `error.category`: one bounded safe-error category, only for failures. +- `observability.drop.reason`: `overflow`, `timeout`, `sink_error`, `panic`, or `shutdown` only on + the dropped-record metric. + +Forbidden metric attributes include topic/destination, queue, subscription/consumer ID, client ID, +message ID, correlation/trace ID, address, header values, message body, raw error text, and arbitrary +user labels. + +## Event kinds + +| Kind | Default severity | Required context | +|---|---|---| +| `connection.started` | Info | from/to state | +| `connection.ready` | Info | from/to state | +| `connection.failed` | Error | state, safe error | +| `connection.stopped` | Info | from/to state | +| `reconnect.started` | Warn | from/to state, attempt count | +| `reconnect.succeeded` | Info | from/to state, attempt count | +| `reconnect.failed` | Error | state, attempt count, safe error | +| `operation.failed` | Error | operation, state, safe error | +| `observability.dropped` | Warn | aggregate drop reason/count | +| `capability.unsupported` | Warn | requested capability | + +Successful high-frequency publish/receive/handler operations are measurements, not structured +events by default. This prevents log floods. Failure events must become available to the dispatcher +within one second of detection when configured capacity and sink budget are not exhausted. + +## Correlation fields + +Reserved logical fields are W3C `traceparent` and `tracestate`. Each adapter maps them to its native +header/attribute representation while preserving case and syntax rules required by that protocol. +Invalid inbound values are ignored and recorded only as a bounded `internal`/`invalid_context` +diagnostic; they never become parent context. + +Default conflict policy is `preserve`: if the outgoing message already contains a reserved field, +the library leaves it untouched and skips injection for that field. Explicit `replace` permits +replacement with the current valid context. The library never injects baggage or payload content by +default. diff --git a/specs/006-observability-diagnostics/data-model.md b/specs/006-observability-diagnostics/data-model.md new file mode 100644 index 0000000..929832d --- /dev/null +++ b/specs/006-observability-diagnostics/data-model.md @@ -0,0 +1,131 @@ +# Data Model: Opt-in Observability and Diagnostics + +All public values are immutable snapshots: callers receive copied maps/slices and cannot mutate +runtime state. Times are UTC wall-clock values; durations use a monotonic source internally. + +## Observability Configuration + +| Field | Type | Rules | +|---|---|---| +| Enabled categories | Bit set | Logging, health, diagnostics, measurements, correlation, and state events are independent; empty by default | +| Instance ID | String | Optional caller value or generated process-local opaque value; never derived from credentials/address | +| Event sink | Interface | Required only for structured logging/state records; invoked asynchronously | +| Buffer capacity | Integer | Non-negative, validated upper bound; zero means documented default when sink enabled | +| Overflow policy | Enum | `drop-newest` default or `drop-oldest` | +| Sink time budget | Duration | Positive and bounded; controls dispatcher callback budget, never broker operations | +| Probe timeout | Duration | Optional default; caller deadline wins when earlier | +| Redacted fields | Set of strings | Case-insensitive additions merged with mandatory defaults | +| Correlation conflict policy | Enum | `preserve` default; optional explicit `replace` | + +Configuration is validated during `Init`/construction. Runtime enablement changes only the atomic +category mask; buffer sizing, sink, redaction, and conflict policy are immutable until re-init. + +## Broker Lifecycle State + +Values: `unknown`, `connecting`, `ready`, `degraded`, `reconnecting`, `stopped`. + +```text +unknown ──Connect──> connecting ──success──> ready + │ │ │ + └──Disconnect──────> stopped <──Disconnect┘ + ▲ │ + │ partial failure + │ ▼ + └────────────── degraded + │ + connection lost + ▼ + reconnecting + │ │ + success failure/retry + ▼ └── reconnecting + ready +``` + +Invalid or duplicate native notifications do not panic; they retain the last valid state and may +emit a categorized internal diagnostic. Repeated Connect/Disconnect remains idempotent. + +## Health Status + +| Field | Type | Meaning | +|---|---|---| +| CheckedAt | Time | When this passive snapshot was created | +| State | Lifecycle state | Current synchronized state | +| Connected | Boolean | Native transport/client connection is available | +| Ready | Boolean | Broker can perform its configured publish/consume role | +| Degraded | Boolean | Some but not all expected capability is available | +| LastConnectedAt | Optional time | Most recent successful connection | +| LastReadyAt | Optional time | Most recent transition to ready | +| Error | Optional safe error | Most recent relevant categorized failure | +| Capabilities | Bounded map | Known support flags only; no vendor objects | + +`Health()` performs no I/O. A `Probe(ctx)` result uses the same shape and updates `CheckedAt`; probe +failure does not overwrite lifecycle truth without an adapter-confirmed transition. + +## Diagnostic Snapshot + +| Field | Type | Validation | +|---|---|---| +| Broker system | Enum/string | Fixed adapter name | +| Instance ID | String | Safe opaque ID | +| Health | Health Status | Copy captured atomically with counters | +| Connection timestamps | Optional times | First/current/last success as available | +| Reconnect count | Unsigned integer | Monotonic | +| Subscription count | Integer | Non-negative current gauge | +| In-flight operations | Bounded map | Fixed operation keys, non-negative | +| Operation totals | Bounded map | Fixed operation/outcome keys, monotonic | +| Last failure | Optional safe error | Category and allowlisted summary only | +| Dropped records | Unsigned integer | Monotonic overflow/timeout/panic count | +| Unsupported capabilities | Set | Fixed capability names | + +## Safe Error + +| Field | Type | Rules | +|---|---|---| +| Category | Enum | `configuration`, `authentication`, `authorization`, `connection`, `timeout`, `canceled`, `publish`, `consume`, `handler`, `ack`, `nack`, `reconnect`, `shutdown`, `unsupported`, `internal`, `unknown` | +| Code | Optional string | Bounded adapter/provider code after allowlist validation | +| Summary | String | Length bounded and redacted; never a raw serialized vendor object | +| Retryable | Optional boolean | Present only when adapter can determine it safely | + +## Broker State Event + +| Field | Type | Rules | +|---|---|---| +| Sequence | Unsigned integer | Monotonic per broker instance | +| Timestamp | Time | UTC | +| Broker system / instance | Strings | Bounded safe identity | +| Kind | Enum | Connect, disconnect, reconnect start/success, operation failure, dropped-record notice | +| From / To | Lifecycle states | Required for transitions | +| Operation | Enum | Fixed signal catalog value | +| Severity | Enum | Debug, info, warn, error | +| Outcome | Enum | Success, failure, canceled, unsupported | +| Error | Optional safe error | Required for failure when known | +| Attributes | Map | Fixed allowlisted keys and bounded values only | + +Ordering is the runtime enqueue order for one broker instance. Delivery may have gaps when overflow +occurs; gaps are detectable through sequence numbers and dropped-record count. + +## Operational Measurement + +| Field | Type | Rules | +|---|---|---| +| Name | Fixed string | Defined in `contracts/signal-catalog.md` | +| Kind | Counter, histogram, or up/down counter | Fixed per name | +| Value | Number/duration | Non-negative except decrement operations on gauges | +| Attributes | Fixed map | Broker system, operation, outcome, error category only | + +No destination, message ID, client ID, queue, header, raw error, or body is a measurement +dimension. + +## Correlation Context + +| Field | Type | Rules | +|---|---|---| +| Trace parent | String | W3C-compatible syntax and size validation | +| Trace state | Optional string | W3C-compatible, size bounded | +| Baggage | Not propagated by default | Requires future explicit policy due to sensitive/high-cardinality risk | +| Conflict result | Enum | Injected, preserved, replaced, unsupported, invalid | + +Correlation metadata is copied into broker-native message attributes only when enabled and +supported. Extraction creates a child processing context without mutating the received `Message` +visible to the user. diff --git a/specs/006-observability-diagnostics/plan.md b/specs/006-observability-diagnostics/plan.md new file mode 100644 index 0000000..2976755 --- /dev/null +++ b/specs/006-observability-diagnostics/plan.md @@ -0,0 +1,176 @@ +# Implementation Plan: Opt-in Observability and Diagnostics + +**Branch**: `006-observability-diagnostics` | **Date**: 2026-08-02 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `specs/006-observability-diagnostics/spec.md` + +## Summary + +Add independently selectable logging, local health, diagnostic snapshots, measurements, +correlation, and state events without changing the existing `Broker` interface or default runtime +behavior. The root package will provide vendor-neutral contracts and a concurrency-safe per-broker +diagnostics runtime. Adapters will report lifecycle and operation outcomes into that runtime and +expose it through optional capability interfaces. Structured events use a bounded, panic-isolated +dispatcher; OpenTelemetry remains an opt-in integration through the existing tracer and meter. + +## Technical Context + +**Language/Version**: Go 1.24.0 +**Primary Dependencies**: Go standard library; existing OpenTelemetry trace/metric APIs; existing +adapter SDKs (no new production dependency planned) +**Storage**: In-memory bounded counters, state, and recent safe failure only; no persistence +**Testing**: `go test`, table-driven adapter tests, `go test -race`, benchmarks, fuzz/redaction +tests, and Docker-tagged Kafka/RabbitMQ/NATS/Redis integration tests +**Target Platform**: Go library on Linux, macOS, and Windows; broker services may be local, +containerized, cloud-hosted, or externally managed +**Project Type**: Single Go module/library with adapter subpackages +**Performance Goals**: Less than 1% throughput change and no workers when disabled; less than 5% +throughput change for the standard enabled package; diagnostic failure visibility within one second +**Constraints**: Existing `Broker` implementations continue compiling; no message bodies or secrets +in signals; bounded dimensions and buffers; observer failures cannot change delivery semantics; +passive health performs no network I/O +**Scale/Scope**: Root contracts plus noop and seven adapters (Kafka, RabbitMQ, NATS, Redis, +RocketMQ, SQS, GCP Pub/Sub); validate at least 100,000 unique message IDs/destinations for bounded +cardinality + +## Constitution Check + +*GATE: Passed before Phase 0 and re-checked after Phase 1 design.* + +- [x] **Interface-Driven**: The existing `Broker` interface is unchanged. `Observable`, + `HealthChecker`, and `DiagnosticsProvider` are optional vendor-neutral capability interfaces. +- [x] **Reliability**: This is not a new adapter. Instrumentation surrounds operations without + changing handler, Ack, Nack, retry, or redelivery decisions. +- [x] **Binary Efficiency**: Contracts and runtime live in the root package and import no adapter; + adapter packages depend inward on the root package only. +- [x] **Licensing**: No new production dependency is planned; existing OpenTelemetry integration is + reused. Any implementation-time addition requires MIT compatibility and vulnerability review. +- [x] **Options Pattern**: `WithObservability` and nested typed functional options extend `Options`; + unsupported adapter behavior is reported rather than ignored. +- [x] **Behavioral Consistency**: Event and measurement emission observes final outcomes and cannot + select or alter Ack/Nack behavior. +- [x] **Doc/Ex**: The plan includes a runnable example plus semantically aligned Chinese and English + README and adapter limitation updates. + +### Post-Design Re-check + +Phase 1 contracts keep vendor SDK types out of the root package, preserve the existing interface, +bound asynchronous work, propagate cancellation for probes, and explicitly document unsupported +capabilities. No constitution violation or exception is required. + +## Design Approach + +### Public capability discovery + +Callers retain a `Broker` and use type assertions against small optional interfaces. Supported +adapters expose a shared diagnostics handle; third-party broker implementations require no source +change. Disabled or unsupported features return explicit status or `ErrUnsupported`, never panic. + +### Shared runtime and adapter instrumentation + +The root package owns configuration validation, immutable snapshots, lifecycle transitions, +counters, redaction, correlation helpers, and bounded event dispatch. Each adapter owns the truth +about its native client and updates the shared runtime at connect/disconnect/reconnect, +publish/receive/handler/Ack/Nack/unsubscribe boundaries. This prevents eight divergent +implementations of security and concurrency policy. + +### Signal delivery and isolation + +Structured diagnostic/state records enter a fixed-capacity queue only when their category is +enabled. A single optional worker invokes the user sink with panic recovery. Overflow follows the +configured `drop-newest` or `drop-oldest` policy and increments a dropped-record counter. Shutdown +is bounded and never waits indefinitely for a user sink. Metrics are recorded directly through the +configured meter because OpenTelemetry instruments are non-blocking by contract; sink callbacks +never execute on broker operation goroutines. + +### Health and probing + +`Health()` reads synchronized local state only. `Probe(ctx)` is separate, respects cancellation, +and delegates to an adapter-specific minimal operation. Adapters without a meaningful probe return +`ErrUnsupported` while still providing local health. Readiness is role-aware and distinct from a +raw connection flag. + +### Security and cardinality + +Diagnostic records use an allowlist and error categorization rather than serializing configuration, +headers, vendor objects, or raw connection errors. Default secret names are always redacted and +user additions extend rather than replace them. Topic/destination, message ID, arbitrary header, +client ID, and raw error text are excluded from metric attributes. Correlation uses reserved +W3C-compatible header names, preserves an existing valid upstream context, and applies a documented +conflict policy. + +## Project Structure + +### Documentation (this feature) + +```text +specs/006-observability-diagnostics/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ ├── public-api.md +│ ├── signal-catalog.md +│ └── example.md +├── checklists/ +│ └── requirements.md +└── tasks.md # generated by speckit-tasks, not this phase +``` + +### Source Code (repository root) + +```text +broker.go # optional capability interfaces and sentinel errors +options.go # observability functional options +observability.go # state, health, snapshot, event, and measurement contracts +observability_runtime.go # synchronized state and bounded dispatcher +observability_redact.go # allowlist and redaction helpers +observability_context.go # correlation inject/extract helpers +observability_test.go +observability_benchmark_test.go + +brokers/ +├── kafka/kafka.go +├── rabbitmq/rabbitmq.go +├── nats/nats.go +├── redis/redis.go +├── rocketmq/rocketmq.go +├── sqs/sqs.go +└── pubsub/pubsub.go # adapter lifecycle/operation instrumentation and probes + +middleware/ +├── otel.go # retained tracing middleware; aligned semantic names +└── otel_test.go + +integration/docker_test.go # enabled/disabled observability and recovery checks +examples/observability/main.go +examples/observability/README.md +README.md +README_EN.md +ADAPTER_EXTENSIONS.md +``` + +**Structure Decision**: Extend the existing single Go module. Vendor-neutral contracts and runtime +remain in the root package, while native probes and lifecycle reporting remain in adapter packages. +No service, persistence, dashboard, or second module is introduced. + +## Verification Strategy + +1. Contract tests exercise disabled defaults, independent category enablement, capability + discovery, immutable snapshots, lifecycle transitions, probe cancellation, sink panic/latency, + queue overflow, redaction, and bounded attributes. +2. Every adapter uses the same behavior table for lifecycle and operation outcome reporting; + vendor-specific mock tests cover native error mapping and unsupported probes. +3. Docker tests verify Kafka, RabbitMQ, NATS, and Redis publish/consume signals and at least one + disconnect/recovery path supported reliably by the container setup. +4. RocketMQ, SQS, and Pub/Sub use strongest available mocked contract tests and document the + real-service validation procedure. +5. Benchmarks compare disabled, logging-only, and standard-enabled publish/handle paths; CI runs + formatting, vet/lint, full unit tests, race detector, coverage, vulnerability scan, and required + Docker integration. +6. CI compiles and smoke-runs the observability example in disabled mode, then runs its switch + matrix through tests so every documented category and failure scenario stays executable. + +## Complexity Tracking + +No constitution violations require justification. diff --git a/specs/006-observability-diagnostics/quickstart.md b/specs/006-observability-diagnostics/quickstart.md new file mode 100644 index 0000000..1ebfc8e --- /dev/null +++ b/specs/006-observability-diagnostics/quickstart.md @@ -0,0 +1,151 @@ +# Validation Quickstart: Opt-in Observability and Diagnostics + +This guide defines runnable acceptance checks for the implementation phase. It intentionally omits +implementation bodies; public behavior is defined in [public-api.md](contracts/public-api.md) and +signals in [signal-catalog.md](contracts/signal-catalog.md). + +## Prerequisites + +- Go 1.24 or later in the Go 1.24 release line +- Docker with Compose support for integration scenarios +- Repository checked out on `006-observability-diagnostics` + +## 1. Run the observability example + +Compile and view the switches described in [example.md](contracts/example.md): + +```bash +go build ./examples/observability +go run ./examples/observability -help +``` + +Validate the master switch and representative independent categories: + +```bash +go run ./examples/observability +go run ./examples/observability -observe -logs +go run ./examples/observability -observe -health -diagnostics +go run ./examples/observability -observe -metrics -correlation -events +go run ./examples/observability -observe -all -scenario=publish-failure +``` + +Expected results: + +- Without `-observe`, normal publish/subscribe works with no new observability output. +- A category flag without `-observe` is rejected with a clear usage error. +- Enabled categories produce only their documented output. +- Failure scenarios print safe categorized diagnostics and never print the message body or secret + fixture values. + +## 2. Baseline and disabled-mode compatibility + +```bash +go test -count=1 ./... +go test -race -count=1 ./... +``` + +Run the observability benchmarks for the unconfigured broker and compare against the stored same- +machine baseline. Expected results: + +- Existing programs compile without implementing a new `Broker` method. +- No new log/event/metric output occurs. +- No dispatcher goroutine or active probe is started. +- Throughput delta is below 1% within the benchmark confidence policy. + +## 3. Independent category checks + +Run the table-driven root and noop contract tests: + +```bash +go test -count=1 -run 'TestObservability|TestNoopObservability' ./... +``` + +The table must enable logging, health, diagnostics, measurements, correlation, and state events one +at a time. Expected results: + +- Only the selected category emits or becomes queryable. +- A disabled category returns a safe empty/disabled result, never a panic. +- Runtime category changes are race-free while publish and health queries are in flight. + +## 4. Failure diagnostics and redaction + +```bash +go test -count=1 -run 'Test(Failure|.*Redact|.*Sensitive|MetricCardinality)' ./... +go test -fuzz=FuzzObservabilityRedaction -fuzztime=10s ./... +``` + +Use fixtures containing password-bearing URLs, tokens, signed query strings, private-key markers, +custom secret header names, 100,000 unique message IDs, and 100,000 destinations. Expected results: + +- Records identify adapter, instance, operation, state, category, and time. +- No secret, message body, signed query, or raw vendor object appears. +- Metric attribute key/value sets remain bounded as specified in the signal catalog. + +## 5. Health, lifecycle, and probe cancellation + +```bash +go test -count=1 -run 'Test(Health|ConcurrentHealth|Probe|ObservabilityProbe)' ./... +``` + +Validate unknown → connecting → ready → degraded/reconnecting → ready → stopped. Query health +concurrently at every transition. Cancel an active probe and test an adapter with no active probe. +Expected results: + +- Passive health performs no network call. +- Ready and connected are independently represented. +- Probe cancellation is prompt and unsupported probes match `ErrUnsupported`. +- Returned snapshots cannot mutate subsequent results. + +## 6. Sink isolation and overflow + +```bash +go test -race -count=1 -run 'Test(EventSink|EventOverflow|RuntimeOverflow|Concurrent)' ./... +``` + +Exercise a sink that blocks, returns errors, and panics under both overflow policies. Expected +results: + +- Publish, handler, Ack, and Nack outcomes are unchanged. +- Queue memory remains within its configured bound. +- Drops are counted and sequence gaps are detectable. +- Disconnect completes within the documented shutdown budget. + +## 7. Docker-backed adapter validation + +```bash +make integration-test +``` + +The integration suite must cover Kafka, RabbitMQ, NATS, and Redis with diagnostics enabled, verify +publish/consume measurement outcomes and correlation where native metadata permits it, and exercise +a deterministic recovery scenario where supported by the compose environment. Expected results: + +- State and operation semantics match the shared contracts across all four adapters. +- Message bodies remain absent from signals. +- Delivery and Ack/Nack behavior matches the pre-feature integration tests. + +## 8. Cloud/RocketMQ contract validation + +```bash +go test -count=1 ./brokers/rocketmq ./brokers/sqs ./brokers/pubsub +``` + +Mocked tests must cover native error mapping, lifecycle, metadata correlation, and unsupported +capability reporting. Before release, follow `ADAPTER_EXTENSIONS.md` to run the same scenarios +against a disposable RocketMQ service, AWS SQS queue, and GCP Pub/Sub project; attach sanitized +results to the release evidence. + +## 9. Full repository gates + +```bash +gofmt -l . +go vet ./... +make lint +go test -count=1 -coverprofile=coverage.out ./... +go test -race -count=1 ./... +go tool cover -func=coverage.out +``` + +Expected results: no formatting, vet, lint, unit, or race failures; repository statement coverage is +at least 75%; changed behavior is directly exercised. Also run the configured vulnerability scan +and verify Chinese/English README examples compile and remain semantically aligned. diff --git a/specs/006-observability-diagnostics/research.md b/specs/006-observability-diagnostics/research.md new file mode 100644 index 0000000..30bdb68 --- /dev/null +++ b/specs/006-observability-diagnostics/research.md @@ -0,0 +1,165 @@ +# Phase 0 Research: Opt-in Observability and Diagnostics + +## Optional capability model + +**Decision**: Preserve `Broker` and expose observability through small optional interfaces detected +with Go type assertions. + +**Rationale**: Adding methods to `Broker` would break every third-party implementation. Optional +interfaces are idiomatic, preserve SemVer compatibility, and let adapters explicitly expose only +supported capabilities. + +**Alternatives considered**: + +- Add health and diagnostics methods to `Broker`: rejected because it is source-incompatible. +- Global registry keyed by broker: rejected because ownership, cleanup, and identity become + ambiguous and global mutable state harms tests. +- Wrapper-only instrumentation: rejected because a wrapper cannot reliably observe native + reconnects, receive failures, or Ack/Nack internals. + +## Shared runtime placement + +**Decision**: Implement the state machine, snapshot aggregation, redaction, correlation helpers, +and event dispatcher in the root package; adapters feed it native outcomes. + +**Rationale**: Security policy, state semantics, and concurrency behavior must be consistent across +adapters. The root package remains vendor-neutral and adapters already import it. + +**Alternatives considered**: + +- Duplicate a runtime in every adapter: rejected due to drift and repeated race/security risk. +- Put runtime in `middleware`: rejected because middleware cannot see all adapter lifecycle events. +- New internal package imported by root and adapters: viable, but public contracts would still need + root facades and the extra boundary adds little value at current size. + +## Configuration and runtime enablement + +**Decision**: Add a nested `ObservabilityConfig` configured by functional options, with immutable +validated settings and an atomic category mask for runtime enable/disable. Categories remain off +unless explicitly enabled; existing `Logger`, `Tracer`, and `Meter` continue to work compatibly. + +**Rationale**: A single namespace avoids expanding `Options` with many unrelated fields, while the +atomic mask handles concurrent toggles without rebuilding clients. Existing integrations cannot be +removed in a backward-compatible feature. + +**Alternatives considered**: + +- Environment variables: rejected because a library should not own process-global configuration. +- Mutable exported config fields: rejected because concurrent mutation would race and bypass + validation. +- Require restart to change categories: simpler, but does not cover the specified in-flight toggle + edge case. + +## Logging and structured event contract + +**Decision**: Retain legacy `Logger`; add a structured `EventSink` fed through a bounded asynchronous +dispatcher. Records contain typed safe fields and categorized errors, not formatted vendor objects. + +**Rationale**: The existing two-method logger cannot guarantee structured safe data or isolate slow +callbacks. A separate sink enables deterministic tests and custom integrations without requiring a +logging framework dependency. + +**Alternatives considered**: + +- Adopt `slog` as the sole logger: rejected because replacing `Logger` is a compatibility change; + a future adapter can bridge records to `slog`. +- Call sinks synchronously: rejected because a slow or panicking sink could alter broker latency or + delivery behavior. +- Unbounded goroutine per record: rejected because failure storms could exhaust memory. + +## Overflow and shutdown policy + +**Decision**: Use a fixed-capacity queue, default `drop-newest`, optional `drop-oldest`, and an +observable dropped-record counter. Dispatcher shutdown is bounded; pending records are best effort. + +**Rationale**: Broker correctness is more important than diagnostic completeness. A bounded queue +makes memory behavior predictable, while both policies cover freshness versus chronology needs. + +**Alternatives considered**: + +- Block producer: rejected because it couples diagnostics backpressure to message processing. +- Spill to disk: rejected as persistence is outside scope and introduces I/O/security concerns. +- Unbounded queue: rejected because it fails safely only under light load. + +## Health and active probes + +**Decision**: Separate synchronous passive `Health()` from cancelable `Probe(ctx)`. Readiness is +derived from adapter role/capability and can be unknown or degraded independently of connection. + +**Rationale**: Read-only health must be safe for frequent service health endpoints and must not +surprise users with network traffic. Explicit probes can have cost and adapter limitations. + +**Alternatives considered**: + +- Probe on every health query: rejected because it adds latency, traffic, and outage amplification. +- Boolean `IsConnected`: rejected because it cannot represent degraded or role-specific readiness. +- Background periodic probe: rejected because disabled mode must have no workers/network calls. + +## Metrics and dimensions + +**Decision**: Reuse the configured OpenTelemetry `metric.Meter`, define a fixed signal catalog, and +allow only bounded attributes: broker system, operation, outcome, error category, and optional +adapter capability. Destination, client/message IDs, headers, and raw errors are excluded. + +**Rationale**: Existing dependencies already provide a vendor-neutral export path. Fixed attributes +protect collectors from cardinality explosions and satisfy the no-built-in-backend scope. + +**Alternatives considered**: + +- Custom metrics registry: rejected because it duplicates aggregation/export concerns. +- Destination as a default attribute: rejected because user-created destinations may be unbounded. +- Prometheus client dependency: rejected because it forces one exporter and increases binary size. + +## Correlation transport + +**Decision**: Use reserved, documented W3C-compatible context headers and OpenTelemetry propagation +when configured. Preserve valid upstream context; default conflict policy is preserve-user and skip +injection with a safe diagnostic event. + +**Rationale**: W3C context is interoperable and already supported by OpenTelemetry. Never silently +overwriting user metadata protects application semantics. + +**Alternatives considered**: + +- Broker-specific IDs: rejected because they do not correlate across adapters/services. +- Always overwrite reserved headers: rejected because it mutates user data unexpectedly. +- Put correlation in the body: rejected because it changes payload contracts. + +## Error safety and redaction + +**Decision**: Produce safe error categories and allowlisted summaries; apply mandatory key/value +redaction to any optional text. User redaction additions extend the non-removable default set. + +**Rationale**: Raw vendor errors can embed signed URLs, credentials, addresses, or tokens. An +allowlist is safer than attempting to enumerate every secret representation. + +**Alternatives considered**: + +- Emit `err.Error()` unchanged: rejected due to credential leakage risk. +- Regex-only scrubbing: retained as defense in depth, but insufficient as the primary policy. +- Let users disable default redaction: rejected because secure defaults are constitutional. + +## Dependency and licensing impact + +**Decision**: Add no production dependency. Use the standard library and existing OpenTelemetry +modules. + +**Rationale**: This preserves module isolation, licensing posture, and downstream binary size. + +**Alternatives considered**: + +- Add a logging or metrics framework: rejected because consumers already choose their backend. + +## Adapter verification scope + +**Decision**: Run Docker-backed tests for Kafka, RabbitMQ, NATS, and Redis; use mock contracts plus +documented real-service validation for RocketMQ, SQS, and Pub/Sub. + +**Rationale**: This matches the constitution and existing compose environment. Cloud credentials +and a RocketMQ service are not reliably available in CI. + +**Alternatives considered**: + +- Mock-only for all adapters: rejected as insufficient evidence for lifecycle and propagation. +- Add all services to default Docker CI: rejected due to cloud credentials and disproportionate + RocketMQ setup cost; this can be revisited separately. diff --git a/specs/006-observability-diagnostics/spec.md b/specs/006-observability-diagnostics/spec.md new file mode 100644 index 0000000..fe79bae --- /dev/null +++ b/specs/006-observability-diagnostics/spec.md @@ -0,0 +1,284 @@ +# Feature Specification: Opt-in Observability and Diagnostics + +**Feature Branch**: `006-observability-diagnostics` +**Created**: 2026-08-02 +**Status**: Draft +**Input**: User description: "在当前项目上新增可观测、故障排查、日志等相关功能,供使用者按需开启" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Enable Only Needed Observability (Priority: P1) + +As a broker library user, I want observability features to be disabled by default and independently +selectable so that I can gain operational insight without paying for or configuring features I do +not use. + +**Why this priority**: Optional adoption preserves backward compatibility and prevents a monitoring +feature from becoming a mandatory runtime dependency. + +**Independent Test**: Create brokers with no observability configuration, with logging only, and +with diagnostics only; verify each configuration exposes only the selected signals and preserves +normal message behavior. + +**Acceptance Scenarios**: + +1. **Given** an existing application with no observability configuration, **When** it upgrades the + library, **Then** no new logs, diagnostics events, traces, or metrics are emitted. +2. **Given** a user enables one signal category, **When** broker operations occur, **Then** that + category is available without requiring the other categories. +3. **Given** observability is enabled, **When** publishing and consuming messages, **Then** message + delivery and acknowledgment outcomes remain unchanged. + +--- + +### User Story 2 - Troubleshoot Broker Failures (Priority: P1) + +As an operator, I want structured, correlated information about connection, publication, +consumption, acknowledgment, retry, and shutdown failures so that I can identify the affected +broker and operation without reproducing the incident locally. + +**Why this priority**: Failure diagnosis is the primary user value and directly reduces recovery +time during production incidents. + +**Independent Test**: Cause connection, publication, handler, acknowledgment, and reconnection +failures and verify that each produces a safe diagnostic record containing the broker, operation, +time, state, and causal error. + +**Acceptance Scenarios**: + +1. **Given** diagnostic logging is enabled, **When** a broker operation fails, **Then** the user + receives a structured record identifying the broker type, operation, destination when safe, + timestamp, state, and error category. +2. **Given** a connection repeatedly disconnects and recovers, **When** an operator inspects the + diagnostics, **Then** the current state, last failure, last successful connection, and reconnect + count are available. +3. **Given** configuration or message metadata contains credentials or secrets, **When** any + diagnostic signal is emitted, **Then** sensitive values are absent or redacted. + +--- + +### User Story 3 - Query Health and Runtime State (Priority: P2) + +As a service owner, I want a consistent health and runtime-state view across adapters so that my +service can make readiness decisions and attach useful information to support reports. + +**Why this priority**: A consistent state view makes the abstraction operationally useful without +forcing users to inspect vendor clients. + +**Independent Test**: Query a broker before connection, after successful connection, during a +simulated failure, after recovery, and after shutdown; verify state transitions and timestamps. + +**Acceptance Scenarios**: + +1. **Given** a broker has not connected, **When** health is queried, **Then** it reports that it is + not ready without initiating network activity. +2. **Given** a connected broker can perform its configured role, **When** health is queried, **Then** + it reports ready and includes the most recent successful check time. +3. **Given** only part of a multi-address configuration is usable, **When** health is queried, + **Then** it can report a degraded state rather than only healthy or failed. +4. **Given** diagnostics are disabled, **When** a caller queries unsupported diagnostic capability, + **Then** the absence is explicit and does not cause a panic. + +--- + +### User Story 4 - Monitor Performance and Message Flow (Priority: P2) + +As an SRE, I want consistent counters, timings, and correlations for message operations so that I +can detect elevated failures, latency, retries, reconnects, and in-flight work across adapters. + +**Why this priority**: Aggregate operational signals reveal degradation before individual errors +become an outage. + +**Independent Test**: Run successful and failed publish/consume flows and verify that totals, +errors, durations, acknowledgments, retries, reconnects, and in-flight values reflect the observed +operations without exposing high-cardinality message data. + +**Acceptance Scenarios**: + +1. **Given** operational measurements are enabled, **When** messages are published and handled, + **Then** users can observe operation totals, failures, durations, and in-flight work. +2. **Given** correlation is enabled, **When** a message flows from publication to handling, **Then** + the related operations can be associated across the boundary where the broker preserves + metadata. +3. **Given** a message has a unique identifier or arbitrary destination, **When** aggregate + measurements are emitted, **Then** unbounded values are not used as aggregate dimensions. + +--- + +### User Story 5 - Observe State Changes Programmatically (Priority: P3) + +As an advanced user, I want optional notifications for important broker state changes so that I can +integrate the broker with custom incident handling and diagnostics workflows. + +**Why this priority**: Programmatic observation enables custom integrations but is not required for +the core troubleshooting experience. + +**Independent Test**: Register an observer, trigger connection and failure transitions, and verify +ordered notifications; then make the observer slow or faulty and verify broker operations continue. + +**Acceptance Scenarios**: + +1. **Given** an observer is enabled, **When** the broker connects, disconnects, begins reconnecting, + recovers, or encounters an operation failure, **Then** the observer receives the corresponding + event with safe context. +2. **Given** an observer is slow, fails, or panics, **When** an event is delivered, **Then** broker + message processing and acknowledgment results are not changed. +3. **Given** no observer is configured, **When** state changes occur, **Then** no observer work is + performed. + +### Edge Cases + +- Observability is enabled or disabled while broker operations are already in progress. +- Health is queried concurrently with Connect, Disconnect, Unsubscribe, or reconnection. +- A configured logger, exporter, or observer blocks, fails, or panics. +- An error wraps vendor-specific details containing credentials or signed addresses. +- A destination, client identifier, header, or error message has extremely high cardinality. +- The broker is connected but cannot publish, cannot consume, or has only some reachable addresses. +- Clock changes produce timestamps or durations that would otherwise appear inconsistent. +- Diagnostic buffers reach their configured capacity during a failure storm. +- An adapter cannot provide a requested signal or cannot propagate correlation metadata. +- Multiple brokers with the same adapter type run in one process and must remain distinguishable. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST keep all new observability categories disabled by default. +- **FR-002**: Users MUST be able to enable logging, health, diagnostic snapshots, operational + measurements, correlation, and state-change observation independently. +- **FR-003**: Enabling or disabling an observability category MUST NOT change publishing, + consumption, retry, acknowledgment, or shutdown semantics. +- **FR-004**: The system MUST expose a consistent broker state vocabulary covering at least unknown, + connecting, ready, degraded, reconnecting, and stopped states. +- **FR-005**: Health information MUST distinguish connection state from readiness to perform the + configured publish or consume role. +- **FR-006**: A health query MUST expose the check time, current state, readiness, last successful + connection time, and most recent safe error summary when available. +- **FR-007**: A local state query MUST NOT initiate network activity unless the user explicitly + requests an active health probe. +- **FR-008**: Active health probes MUST respect caller cancellation and a user-controlled timeout. +- **FR-009**: Diagnostic snapshots MUST include broker type, a non-secret instance identity, + lifecycle state, connection timestamps, reconnect count, subscription count, in-flight work, and + the most recent safe failure details when available. +- **FR-010**: Structured failure records MUST cover connection, publication, consumption, handler, + acknowledgment, negative acknowledgment, retry, reconnection, unsubscription, and shutdown + operations. +- **FR-011**: Every structured failure record MUST include a timestamp, broker instance, operation, + severity, state, and error category; destination and consumer identity MAY be included only when + safe and bounded. +- **FR-012**: The system MUST redact credentials, tokens, passwords, private keys, signed query + parameters, and credential-bearing connection strings from every observability output. +- **FR-013**: Users MUST be able to configure additional header and field redaction without replacing + the default protected-field set. +- **FR-014**: Operational measurements MUST cover totals, failures, and duration for publish, + receive, handler, Ack, and Nack operations, plus retries, reconnects, and current in-flight work. +- **FR-015**: Aggregate measurements MUST NOT use message identifiers, arbitrary headers, full error + text, or other unbounded values as dimensions. +- **FR-016**: Correlation MUST preserve an existing upstream context and associate publication with + downstream handling when the selected broker preserves the required metadata. +- **FR-017**: Correlation metadata MUST use documented reserved fields and MUST NOT overwrite user + metadata with the same name without an explicit conflict policy. +- **FR-018**: The system MUST expose unsupported observability capabilities explicitly rather than + silently ignoring a requested category. +- **FR-019**: State-change observation MUST cover connect, disconnect, reconnect start, reconnect + success, publish failure, consume failure, handler failure, and acknowledgment failure. +- **FR-020**: Slow, failing, or panicking observability consumers MUST be isolated from broker message + processing and MUST NOT change acknowledgment outcomes. +- **FR-021**: Users MUST be able to bound memory used for buffered diagnostic events and select a + documented overflow behavior. +- **FR-022**: Concurrent observability queries and emissions MUST return internally consistent data + without races or exposing mutable internal state. +- **FR-023**: Disabled observability MUST avoid background workers, network calls, and externally + visible side effects. +- **FR-024**: Existing applications and third-party broker implementations MUST continue to compile + without implementing the new optional observability capabilities. +- **FR-025**: Chinese and English documentation MUST describe enablement, signal meanings, redaction, + overhead expectations, unsupported capabilities, and troubleshooting examples consistently. +- **FR-026**: The repository MUST provide a runnable observability example with a master enable + switch and independent logging, health, diagnostics, measurements, correlation, and state-event + switches, including normal message flow and opt-in failure-troubleshooting scenarios. + +### Key Entities + +- **Observability Configuration**: The independently selected signal categories, probe behavior, + redaction additions, buffer limits, and overflow preference for one broker instance. +- **Health Status**: A point-in-time view of lifecycle state, connection, readiness, check time, + last successful connection, and safe error summary. +- **Diagnostic Snapshot**: A non-secret, immutable summary of broker identity, state, timestamps, + reconnects, subscriptions, in-flight work, and recent failure context. +- **Operational Measurement**: A bounded-dimension count, duration, or current value associated with + a broker operation and outcome. +- **Broker State Event**: A timestamped notification of a lifecycle transition or operation failure + containing safe, bounded context. +- **Correlation Context**: Portable metadata that relates upstream publication to downstream + processing without exposing business payloads. + +## Scope + +### In Scope + +- Optional logging, local health and diagnostics, operational measurements, correlation, and broker + state events across supported adapters. +- Passive state queries and explicitly requested active probes. +- Secure redaction and bounded-cardinality defaults. +- Consistent signal names and meanings with documented adapter limitations. +- Unit, race, failure-path, and applicable Docker-backed verification. + +### Out of Scope + +- A hosted monitoring service, dashboard, alert manager, log store, or trace store. +- Persisting diagnostic history after process exit. +- Automatically restarting applications or changing retry and delivery policies in response to + observed failures. +- Broker administration such as creating topics, resizing partitions, or deleting queues. +- Reading or exposing message bodies for diagnostics. +- Guaranteeing correlation when a broker or external intermediary removes required metadata. + +## Assumptions + +- Existing logging, tracing, and measurement integrations remain usable and are consolidated rather + than removed abruptly. +- Users provide any external collectors, exporters, storage, dashboards, or alerting systems. +- A safe local state snapshot is available synchronously; active probes are opt-in and cancelable. +- Default diagnostics never contain message bodies and use an allowlist for metadata exposure. +- Optional capability discovery preserves compatibility with third-party Broker implementations. +- When an adapter cannot provide a meaningful active health probe, it reports the limitation and + still provides passive lifecycle state. + +## Dependencies + +- Existing broker lifecycle, Logger, tracing, measurement, Context, and option-tracking contracts. +- Adapter cooperation for accurate lifecycle state, reconnect counts, acknowledgment failures, and + correlation transport. +- External observability systems only when users choose to export signals beyond the process. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Existing applications with no new configuration produce zero new observability output + and complete the existing compatibility suite without source changes. +- **SC-002**: Users can enable any one supported signal category without configuring another signal + category in 100% of documented examples. +- **SC-003**: For every supported adapter, users can determine current lifecycle state, readiness, + last successful connection, and last safe failure reason through one consistent workflow. +- **SC-004**: Connection, publication, consumption, handler, acknowledgment, reconnection, and + shutdown failures appear in enabled diagnostics within one second of being observed. +- **SC-005**: Automated security tests detect zero credential, token, private-key, signed-query, or + message-body disclosures across all observability outputs. +- **SC-006**: With observability disabled, representative publish and consume throughput changes by + less than 1% and introduces no persistent background activity. +- **SC-007**: With standard logging, diagnostics, measurements, and correlation enabled, + representative publish and consume throughput changes by less than 5% at the same workload. +- **SC-008**: Concurrent health queries, state transitions, and signal emissions complete the full + race and stress suites with zero detected data races or deadlocks. +- **SC-009**: A slow or failing observability consumer causes zero changed Ack/Nack outcomes and zero + blocked broker operations beyond the configured observability time budget. +- **SC-010**: Aggregate measurement dimensions remain within the documented bounded set during a + test containing at least 100,000 unique message identifiers and destinations. +- **SC-011**: At least 90% of troubleshooting test participants can identify the affected broker, + failed operation, and most recent safe error from a diagnostic snapshot without vendor-client + access. +- **SC-012**: The observability example compiles in CI and demonstrates disabled mode, each + independently enabled category, a diagnostic snapshot, and at least connection, publish, and + handler failure scenarios using documented command-line switches. diff --git a/specs/006-observability-diagnostics/tasks.md b/specs/006-observability-diagnostics/tasks.md new file mode 100644 index 0000000..cef4d96 --- /dev/null +++ b/specs/006-observability-diagnostics/tasks.md @@ -0,0 +1,306 @@ +# Tasks: Opt-in Observability and Diagnostics + +**Input**: Design documents from `specs/006-observability-diagnostics/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/`, `quickstart.md` +**Tests**: Required by the feature specification, constitution quality gates, and measurable success criteria. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel because it changes different files and has no dependency on an incomplete task in the same phase. +- **[Story]**: Maps work to one independently testable user story. +- Every task names the exact repository file or directory it changes. + +## Phase 1: Setup and Baseline + +**Purpose**: Establish reproducible pre-feature behavior and shared test organization. + +- [X] T001 Record disabled-mode publish/handle benchmark baselines with Go version and environment metadata in `observability_benchmark_test.go` +- [X] T002 [P] Add reusable fake event sink, fake meter/tracer, controllable clock, and goroutine leak assertions in `observability_test.go` +- [X] T003 [P] Add an adapter-neutral observability conformance test harness skeleton in `internal/obstest/conformance.go` +- [X] T004 Run the pre-change unit, race, coverage, and example compilation gates and record reproducible baseline results in `specs/006-observability-diagnostics/baseline.md` + +--- + +## Phase 2: Foundational Contracts and Runtime + +**Purpose**: Implement the vendor-neutral types and safe runtime that block every user story. + +**⚠️ CRITICAL**: No user-story implementation starts until this phase passes root-package tests and the race detector. + +- [X] T005 [P] Write compile-time and behavior tests for optional capability discovery, sentinel errors, immutable returned values, and third-party `Broker` compatibility in `observability_test.go` +- [X] T006 [P] Write validation tests for category masks, nested functional options, buffer policy, timeouts, instance identity, redaction additions, and conflict policy in `options_test.go` +- [X] T007 [P] Write table and fuzz tests for mandatory secret removal, allowlisted safe errors, length bounds, signed URLs, private keys, and custom protected fields in `observability_redact_test.go` +- [X] T008 Define `Observable`, `Observability`, `HealthChecker`, and `DiagnosticsProvider` optional interfaces plus `ErrUnsupported` and `ErrInvalidObservabilityConfig` in `broker.go` +- [X] T009 Define lifecycle states, categories, operations, outcomes, safe errors, health status, diagnostic snapshots, state events, overflow policies, and sink contracts in `observability.go` +- [X] T010 Implement `ObservabilityConfig`, nested option types, validation, disabled defaults, and atomic runtime category configuration in `options.go` +- [X] T011 Implement mandatory default redaction, user redaction merging, safe error classification, allowlisting, and bounded summaries in `observability_redact.go` +- [X] T012 Implement the synchronized per-broker state machine, immutable snapshot copying, fixed operation counters, in-flight gauges, timestamps, and unsupported capability tracking in `observability_runtime.go` +- [X] T013 Implement runtime construction and cleanup helpers that start no worker or network activity when all categories are disabled in `observability_runtime.go` +- [X] T014 Run root contract, fuzz seed, and race tests for the foundation and document any intentional unsupported behavior in `specs/006-observability-diagnostics/baseline.md` + +**Checkpoint**: Public contracts compile, third-party implementations remain compatible, defaults are inert, and the shared runtime is race-free. + +--- + +## Phase 3: User Story 1 - Enable Only Needed Observability (Priority: P1) 🎯 MVP + +**Goal**: Users independently enable only the required observability categories while existing applications remain behaviorally unchanged. + +**Independent Test**: Construct unconfigured, logging-only, diagnostics-only, and runtime-toggled noop brokers; verify category isolation, zero disabled output/workers, and unchanged publish/subscribe/Ack/Nack outcomes. + +### Tests for User Story 1 + +- [X] T015 [P] [US1] Write failing table-driven tests for absent configuration, each category alone, `-all` equivalent masks, and concurrent category toggles in `observability_test.go` +- [X] T016 [P] [US1] Write failing noop broker tests for optional capability discovery, disabled output, independent enablement, and unchanged delivery outcomes in `noop_broker_test.go` +- [X] T017 [P] [US1] Add a compile-only external third-party `Broker` implementation proving the existing interface remains unchanged in `internal/obstest/compatibility_test.go` + +### Implementation for User Story 1 + +- [X] T018 [US1] Implement atomic `SetCategories` behavior, category dependency validation, and safe disabled-query responses in `observability_runtime.go` +- [X] T019 [US1] Attach an optional observability runtime to the noop broker and report its local lifecycle and message operation outcomes in `noop_broker.go` +- [X] T020 [US1] Implement the base `examples/observability/main.go` command with `-observe`, `-all`, independent category flags, validation, bounded timeout, normal scenario, and deterministic output prefixes +- [X] T021 [US1] Add table-driven CLI parsing and disabled/category-isolation smoke tests for the example in `examples/observability/main_test.go` +- [X] T022 [US1] Re-run disabled and enabled benchmarks and enforce the less-than-1% disabled-overhead target in `observability_benchmark_test.go` + +**Checkpoint**: The MVP supports opt-in discovery and independent toggles on the noop broker, with a runnable switch example and no compatibility break. + +--- + +## Phase 4: User Story 2 - Troubleshoot Broker Failures (Priority: P1) + +**Goal**: Operators receive safe, structured, correlated failure context for lifecycle and message operations across adapters. + +**Independent Test**: Inject connection, publish, consume, handler, Ack/Nack, reconnect, unsubscribe, and shutdown errors; verify safe records contain broker, instance, operation, time, state, severity, and category with no body or secret disclosure. + +### Tests for User Story 2 + +- [X] T023 [P] [US2] Write failure-record contract tests covering every required operation, safe field bounds, one-second availability, and immutable snapshots in `observability_failure_test.go` +- [X] T024 [P] [US2] Add secret-bearing connection strings, tokens, signed queries, custom headers, raw vendor errors, and message-body non-disclosure cases in `observability_redact_test.go` +- [X] T025 [P] [US2] Add connection, publish, consume, handler, Ack/Nack, unsubscribe, and shutdown failure cases to the adapter harness in `internal/obstest/conformance.go` +- [X] T026 [US2] Extend the example tests for `connect-failure`, `publish-failure`, and `handler-failure`, including secret/body absence and unchanged delivery behavior, in `examples/observability/main_test.go` + +### Implementation for User Story 2 + +- [X] T027 [US2] Implement structured failure recording, last-safe-failure retention, severity mapping, monotonic sequence assignment, and one-second enqueue guarantees in `observability_runtime.go` +- [X] T028 [P] [US2] Instrument Kafka lifecycle, publish, receive, handler, Ack/Nack, unsubscribe, and shutdown outcomes in `brokers/kafka/kafka.go` and cover mappings in `brokers/kafka/kafka_test.go` +- [X] T029 [P] [US2] Instrument RabbitMQ lifecycle, reconnect, publish, receive, handler, Ack/Nack, unsubscribe, and shutdown outcomes in `brokers/rabbitmq/rabbitmq.go` and cover mappings in `brokers/rabbitmq/rabbitmq_test.go` +- [X] T030 [P] [US2] Instrument NATS lifecycle, publish, receive, handler, Ack/Nack, unsubscribe, and shutdown outcomes in `brokers/nats/nats.go` and cover mappings in `brokers/nats/nats_test.go` +- [X] T031 [P] [US2] Instrument Redis lifecycle, publish, receive, handler, Ack/Nack, retry, unsubscribe, and shutdown outcomes in `brokers/redis/redis.go` and cover mappings in `brokers/redis/redis_test.go` +- [X] T032 [P] [US2] Instrument RocketMQ lifecycle, publish, consume, handler, retry, unsubscribe, and shutdown outcomes in `brokers/rocketmq/rocketmq.go` and cover mappings in `brokers/rocketmq/rocketmq_test.go` +- [X] T033 [P] [US2] Instrument SQS lifecycle, publish, receive, handler, Ack/Nack, retry, unsubscribe, and shutdown outcomes in `brokers/sqs/sqs.go` and cover mappings in `brokers/sqs/sqs_test.go` +- [X] T034 [P] [US2] Instrument GCP Pub/Sub lifecycle, publish, receive, handler, Ack/Nack, unsubscribe, and shutdown outcomes in `brokers/pubsub/pubsub.go` and cover mappings in `brokers/pubsub/pubsub_test.go` +- [X] T035 [US2] Implement deterministic failure scenarios and safe snapshot printing in `examples/observability/main.go` +- [X] T036 [US2] Run the adapter conformance suite and resolve operation-name, error-category, or redaction inconsistencies in `internal/obstest/conformance_test.go` + +**Checkpoint**: Each adapter exposes actionable failure diagnostics without leaking secrets or changing broker delivery semantics. + +--- + +## Phase 5: User Story 3 - Query Health and Runtime State (Priority: P2) + +**Goal**: Service owners query consistent passive health and explicit cancelable active probes across adapters. + +**Independent Test**: Query before connection, while connecting, when ready/degraded/reconnecting, after recovery, and after shutdown; verify passive queries perform no I/O and unsupported/canceled probes are explicit. + +### Tests for User Story 3 + +- [X] T037 [P] [US3] Write lifecycle transition, readiness-versus-connection, immutable health, concurrent query, and passive-no-I/O tests in `observability_health_test.go` +- [X] T038 [P] [US3] Add probe deadline, caller cancellation, configured timeout, degraded result, and `ErrUnsupported` cases to `internal/obstest/conformance.go` +- [X] T039 [P] [US3] Add health/probe switch validation and before-connect/after-connect/after-disconnect output tests in `examples/observability/main_test.go` + +### Implementation for User Story 3 + +- [X] T040 [US3] Implement passive `Health`, role-aware readiness, adapter probe registration, deadline composition, and unsupported probe handling in `observability_runtime.go` +- [X] T041 [P] [US3] Implement native passive-state reporting and a minimal cancelable active probe for Kafka in `brokers/kafka/kafka.go` +- [X] T042 [P] [US3] Implement native passive-state reporting and a minimal cancelable active probe for RabbitMQ in `brokers/rabbitmq/rabbitmq.go` +- [X] T043 [P] [US3] Implement native passive-state reporting and a minimal cancelable active probe for NATS in `brokers/nats/nats.go` +- [X] T044 [P] [US3] Implement native passive-state reporting and a minimal cancelable active probe for Redis in `brokers/redis/redis.go` +- [X] T045 [P] [US3] Implement best-effort passive state and explicitly supported or unsupported active probing for RocketMQ in `brokers/rocketmq/rocketmq.go` +- [X] T046 [P] [US3] Implement best-effort passive state and explicitly supported or unsupported active probing for SQS in `brokers/sqs/sqs.go` +- [X] T047 [P] [US3] Implement best-effort passive state and explicitly supported or unsupported active probing for GCP Pub/Sub in `brokers/pubsub/pubsub.go` +- [X] T048 [US3] Add `-health` and `-probe` execution, timeout handling, and deterministic state output to `examples/observability/main.go` + +**Checkpoint**: All adapters provide the same passive health workflow and clearly declare active-probe limitations. + +--- + +## Phase 6: User Story 4 - Monitor Performance and Message Flow (Priority: P2) + +**Goal**: SREs observe bounded counters, timings, in-flight work, retries/reconnects, and portable publish-to-handler correlation. + +**Independent Test**: Run successful and failing flows with 100,000 unique IDs and destinations; verify totals/durations/gauges, bounded attributes, and correlation continuity where metadata is preserved. + +### Tests for User Story 4 + +- [X] T049 [P] [US4] Write metric catalog tests for operation totals, failures, durations, retries, reconnects, subscriptions, in-flight balance, and fixed attributes in `observability_metrics_test.go` +- [X] T050 [P] [US4] Write 100,000-identity/destination cardinality tests that reject forbidden metric dimensions in `observability_metrics_test.go` +- [X] T051 [P] [US4] Write W3C correlation inject/extract, invalid context, preserve/replace conflict, absent metadata, and immutable message-header tests in `observability_context_test.go` +- [X] T052 [P] [US4] Add cross-adapter metric and correlation cases to the shared harness in `internal/obstest/conformance.go` + +### Implementation for User Story 4 + +- [X] T053 [US4] Implement fixed-name OpenTelemetry instruments, bounded attributes, monotonic durations, and balanced in-flight accounting in `observability_runtime.go` +- [X] T054 [US4] Implement W3C-compatible correlation injection/extraction and preserve/replace conflict policies without baggage propagation in `observability_context.go` +- [X] T055 [P] [US4] Align producer/consumer span semantics and avoid duplicate emission in `middleware/otel.go` with regression coverage in `middleware/otel_test.go` +- [X] T056 [P] [US4] Map correlation metadata and measurement outcomes for Kafka and RabbitMQ in `brokers/kafka/kafka.go` and `brokers/rabbitmq/rabbitmq.go` +- [X] T057 [P] [US4] Map correlation metadata and measurement outcomes for NATS and Redis in `brokers/nats/nats.go` and `brokers/redis/redis.go` +- [X] T058 [P] [US4] Map correlation metadata and measurement outcomes for RocketMQ, SQS, and Pub/Sub in `brokers/rocketmq/rocketmq.go`, `brokers/sqs/sqs.go`, and `brokers/pubsub/pubsub.go` +- [X] T059 [US4] Add `-metrics` and `-correlation` output and validation to `examples/observability/main.go` +- [X] T060 [US4] Benchmark the standard enabled package and enforce the less-than-5% throughput target in `observability_benchmark_test.go` + +**Checkpoint**: Aggregate monitoring is bounded and consistent, while supported transports preserve correlation without mutating user metadata unexpectedly. + +--- + +## Phase 7: User Story 5 - Observe State Changes Programmatically (Priority: P3) + +**Goal**: Advanced users consume ordered state/failure events without allowing observers to affect broker operations. + +**Independent Test**: Trigger lifecycle and operation events with normal, slow, erroring, panicking, and blocked sinks under both overflow policies; verify ordering/gaps, bounded memory, prompt shutdown, and unchanged Ack/Nack outcomes. + +### Tests for User Story 5 + +- [X] T061 [P] [US5] Write event-kind, ordering, sequence-gap, disabled-worker, dynamic-toggle, and immutable-event tests in `observability_event_test.go` +- [X] T062 [P] [US5] Write slow/error/panic/blocked sink tests for timeout isolation, recursion prevention, bounded shutdown, and unchanged delivery in `observability_event_test.go` +- [X] T063 [P] [US5] Write drop-newest/drop-oldest capacity, drop-reason counter, and failure-storm memory-bound tests in `observability_event_test.go` + +### Implementation for User Story 5 + +- [X] T064 [US5] Implement the single-worker bounded event dispatcher, timeout context, panic recovery, recursion guard, and bounded shutdown in `observability_runtime.go` +- [X] T065 [US5] Implement drop-newest/drop-oldest queue behavior, dropped-record measurements, and coalesced drop notices in `observability_runtime.go` +- [X] T066 [US5] Route lifecycle transitions and operation failures already reported by all adapters into the shared event dispatcher in `observability_runtime.go` +- [X] T067 [US5] Add `-events` and `-all` event-sink demonstrations with deterministic sorted output in `examples/observability/main.go` +- [X] T068 [US5] Complete the example switch/scenario matrix and goroutine-cleanup tests in `examples/observability/main_test.go` + +**Checkpoint**: State observation is useful under normal load and fails safely under overload or hostile sink behavior. + +--- + +## Phase 8: Integration, Documentation, and Cross-Cutting Quality + +**Purpose**: Prove adapter behavior end-to-end and finish all constitutional quality gates. + +- [X] T069 [P] Extend Docker integration coverage for enabled/disabled signals, correlation, handler failure, Ack/Nack invariance, and deterministic recovery across Kafka, RabbitMQ, NATS, and Redis in `integration/docker_test.go` +- [X] T070 [P] Add strongest available mocked observability contracts and real-service validation instructions for RocketMQ, SQS, and Pub/Sub in `integration/cloud_observability_test.go` and `ADAPTER_EXTENSIONS.md` +- [X] T071 [P] Document per-adapter health probes, correlation transport, reconnect visibility, and unsupported capabilities in `ADAPTER_EXTENSIONS.md` +- [X] T072 [P] Add the full switch table, normal/failure commands, safe output guidance, and troubleshooting walkthrough in `examples/observability/README.md` +- [X] T073 [P] Add semantically aligned Chinese observability enablement, signal, security, overhead, and example documentation in `README.md` +- [X] T074 [P] Add semantically aligned English observability enablement, signal, security, overhead, and example documentation in `README_EN.md` +- [X] T075 Add example build and disabled/category/failure smoke commands to pull-request checks in `.github/workflows/pull-request.yml` +- [X] T076 Add observability Docker scenarios and sanitized failure log capture to `.github/workflows/integration.yml` +- [X] T077 Run `gofmt`, dependency verification, vet/lint, unit tests without cache, race detector, at-least-75% coverage, vulnerability scan, and example compilation; record results in `specs/006-observability-diagnostics/validation.md` +- [X] T078 Execute every command and verify every expected outcome in `specs/006-observability-diagnostics/quickstart.md`, then record platform-specific limitations in `specs/006-observability-diagnostics/validation.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 Setup**: Starts immediately. +- **Phase 2 Foundation**: Depends on Phase 1 and blocks all user stories. +- **US1 / Phase 3**: Depends on Phase 2 and is the MVP. +- **US2 / Phase 4**: Depends on Phase 2; complete before final adapter conformance and before US5 routes failure events. +- **US3 / Phase 5**: Depends on Phase 2; may proceed alongside US2 once shared runtime contracts stabilize. +- **US4 / Phase 6**: Depends on Phase 2; correlation adapter edits should be sequenced after overlapping US2/US3 adapter edits. +- **US5 / Phase 7**: Depends on the Phase 2 dispatcher contracts and consumes lifecycle/failure reporting from US2. +- **Phase 8 Polish**: Depends on all selected stories; full validation requires US1-US5. + +### User Story Completion Order + +```text +Setup → Foundation → US1 (MVP) + ├──→ US2 ──→ US5 + ├──→ US3 + └──→ US4 +US2 + US3 + US4 + US5 ──→ Integration and quality gates +``` + +### Within Each User Story + +- Write the listed tests first and confirm they fail for the intended missing behavior. +- Implement root runtime behavior before adapter integration that calls it. +- Run the story's root, noop, adapter, example, and race tests at its checkpoint. +- Do not mark a story complete when a metric, event, or diagnostic path changes delivery decisions. + +### Parallel Opportunities + +- T002 and T003 can run in parallel after T001 begins; T005-T007 can run in parallel. +- After Phase 2, US2 and US3 test design can proceed in parallel with US1 example work. +- Adapter tasks T028-T034 are parallel after T027; T041-T047 are parallel after T040. +- Metric/correlation adapter groups T056-T058 are parallel after T053-T054. +- Event test tasks T061-T063 are parallel before T064-T065. +- Documentation and integration tasks T069-T074 are parallel after their underlying stories finish. + +## Parallel Execution Examples + +### User Story 1 + +```text +T015: Root category and toggle tests in observability_test.go +T016: Noop behavior tests in noop_broker_test.go +T017: Third-party compatibility compile test in internal/obstest/compatibility_test.go +``` + +### User Story 2 + +```text +After T027, implement T028-T034 concurrently, one adapter per worker. +Run T036 only after all selected adapter tasks pass their package tests. +``` + +### User Story 3 + +```text +After T040, implement T041-T047 concurrently, preserving explicit unsupported results where a +provider lacks a meaningful low-impact probe. +``` + +### User Story 4 + +```text +T049: Metric catalog tests +T050: High-cardinality stress tests +T051: Correlation contract tests +T052: Adapter conformance cases +``` + +### User Story 5 + +```text +T061: Ordering and enablement tests +T062: Hostile sink isolation tests +T063: Overflow and memory-bound tests +``` + +## Implementation Strategy + +### MVP First + +1. Complete Setup and Foundation. +2. Complete US1 through T022. +3. Stop and validate disabled compatibility, independent toggles, noop behavior, and the base example. +4. Demo the MVP without requiring Docker or external credentials. + +### Incremental Delivery + +1. Add US2 safe failure diagnostics and adapter mappings. +2. Add US3 passive health and explicit probes. +3. Add US4 bounded measurements and correlation. +4. Add US5 isolated state-event delivery. +5. Complete Docker/cloud evidence, documentation, CI, security, performance, and quickstart gates. + +### Implementation Invocation + +After reviewing this task list, run `$speckit-implement` to execute tasks in dependency order. Each +task or tightly related test/implementation pair should be committed separately so regressions can +be bisected. + +## Notes + +- No new production dependency is planned; any exception requires constitution review. +- Existing `Broker`, `Event`, `Subscriber`, and `Logger` interfaces must remain source compatible. +- Never use raw vendor errors, message bodies, addresses, identifiers, or arbitrary headers as + observability output or metric dimensions. +- Docker-backed evidence is mandatory when common lifecycle, acknowledgment, or adapter behavior + changes for Kafka, RabbitMQ, NATS, or Redis. diff --git a/specs/006-observability-diagnostics/validation.md b/specs/006-observability-diagnostics/validation.md new file mode 100644 index 0000000..6585c2f --- /dev/null +++ b/specs/006-observability-diagnostics/validation.md @@ -0,0 +1,35 @@ +# Implementation Validation + +**Date**: 2026-08-02 + +**Branch**: `006-observability-diagnostics` + +**Task progress**: 78 of 78 tasks complete + +## Quality gates + +- `gofmt -l .` and `git diff --check`: pass; no formatting or whitespace findings. +- `go mod verify`: pass (`all modules verified`). +- `go vet ./...`: pass. +- `golangci-lint run ./...` and `make lint`: pass with isolated writable caches. +- `go test -count=1 ./...`: pass after the final implementation changes. +- `go test -race -count=1 ./...`: pass. The timing-based throughput budget is excluded from race-instrumented builds because race instrumentation changes its cost model. +- Repository statement coverage: **78.7%** (required minimum: 75%); root package: **88.0%**. +- `govulncheck ./...` using `golang.org/x/vuln/cmd/govulncheck@v1.6.0`: pass with no reachable vulnerability findings. +- `go build ./examples/observability`: pass. +- Ten-second redaction fuzz run: pass, 146,391 executions and 112 new interesting inputs in this run. +- Standard enabled representative publish budget: three repeated runs reported -1.49%, 3.11%, and 1.87% overhead, all below 5%. +- Disabled noop publish baseline remains approximately 0.45% from the stored same-machine legacy baseline, below 1%. + +## Quickstart evidence + +- Help, disabled normal execution, independent logging, health/diagnostics, metrics/correlation/events, and categorized publish-failure commands produced the documented safe output. +- Category, failure/redaction/cardinality, lifecycle/probe, sink/overflow/concurrency, cloud-adapter mock, full unit, race, lint, coverage, and example build commands passed. +- `make integration-test` passed against Docker Kafka, RabbitMQ, NATS, and Redis. It verified enabled and dynamically disabled signals, W3C correlation round trips, native Ack/Nack outcomes, Handler failures, stopped-to-ready recovery, and safe body exclusion. Compose containers, volumes, and the network were removed afterward. +- RocketMQ, SQS, and Pub/Sub package tests plus `TestCloudAdapterObservabilityContracts` passed without credentials. + +## Platform-specific limitations + +- The sandbox requires writable task-specific `GOCACHE`, `GOLANGCI_LINT_CACHE`, and temporary module-cache paths. This affects only validation tooling, not project behavior. +- Docker validation required approved access to the local Docker daemon. +- No disposable RocketMQ service, AWS account/queue, or GCP project was supplied. Their strongest mocked contracts passed; real-service release validation remains an environmental release procedure documented in `ADAPTER_EXTENSIONS.md`, not an unverified claim in this report. From 2e45917115a36c94b693fb590e9bc11a2a0ae7a2 Mon Sep 17 00:00:00 2001 From: yzimhao Date: Sun, 2 Aug 2026 19:05:22 +0800 Subject: [PATCH 2/4] Enforce observability performance budget independently --- .github/workflows/pull-request.yml | 5 +++++ README.md | 6 ++++++ observability_benchmark_test.go | 29 ++++++++++++++++++++--------- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index e400c26..2ac20b6 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -79,5 +79,10 @@ jobs: awk -v total="$total" 'BEGIN { if (total < 75) { printf "coverage %.1f%% is below 75%%\n", total; exit 1 } }' printf 'total coverage: %s%%\n' "$total" + - name: Enforce observability performance budget + env: + BROKER_ENFORCE_PERFORMANCE: "1" + run: go test -count=1 -run '^TestObservabilityStandardOverheadBudget$' . + - name: Run race detector run: go test -race -count=1 ./... diff --git a/README.md b/README.md index 6bca0a3..8f38363 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,12 @@ default: 完整示例开关和场景说明见 [`examples/observability`](examples/observability/README.md),各适配器差异见 [`ADAPTER_EXTENSIONS.md`](ADAPTER_EXTENSIONS.md)。禁用模式不启动事件 worker,实测目标热路径开销低于 1%;标准启用组合的代表性吞吐开销门禁为低于 5%。 +性能门禁必须脱离覆盖率和 Race 插桩独立执行,否则插桩会不成比例地放大启用路径: + +```bash +BROKER_ENFORCE_PERFORMANCE=1 go test -count=1 -run '^TestObservabilityStandardOverheadBudget$' . +``` + ## 项目结构 ``` diff --git a/observability_benchmark_test.go b/observability_benchmark_test.go index 1167ebc..7071209 100644 --- a/observability_benchmark_test.go +++ b/observability_benchmark_test.go @@ -5,6 +5,7 @@ package broker import ( "context" "crypto/sha256" + "os" "testing" "time" ) @@ -43,7 +44,7 @@ func BenchmarkObservabilityDiagnosticsPublish(b *testing.B) { } } -func runRepresentativePublish(iterations int, enabled bool) time.Duration { +func runRepresentativePublish(iterations int, enabled bool, payload []byte) time.Duration { var options []Option if enabled { options = append(options, WithObservability(EnableCategories(CategoryDiagnostics, CategoryMeasurements, CategoryCorrelation))) @@ -51,7 +52,6 @@ func runRepresentativePublish(iterations int, enabled bool) time.Duration { candidate := NewNoopBroker(options...) _ = candidate.Connect() defer candidate.Disconnect() - payload := make([]byte, 64*1024) message := &Message{Body: payload} started := time.Now() for index := 0; index < iterations; index++ { @@ -62,14 +62,24 @@ func runRepresentativePublish(iterations int, enabled bool) time.Duration { } func TestObservabilityStandardOverheadBudget(t *testing.T) { - const iterations = 2000 + if os.Getenv("BROKER_ENFORCE_PERFORMANCE") != "1" { + t.Skip("performance budget is opt-in; coverage and race instrumentation distort timing") + } + const iterations = 200 + payload := make([]byte, 1024*1024) // Warm caches before comparing alternating runs. - runRepresentativePublish(100, false) - runRepresentativePublish(100, true) + runRepresentativePublish(10, false, payload) + runRepresentativePublish(10, true, payload) var disabled, enabled time.Duration - for trial := 0; trial < 3; trial++ { - disabled += runRepresentativePublish(iterations, false) - enabled += runRepresentativePublish(iterations, true) + for trial := 0; trial < 6; trial++ { + // Alternate order to cancel CPU frequency and shared-runner drift. + if trial%2 == 0 { + disabled += runRepresentativePublish(iterations, false, payload) + enabled += runRepresentativePublish(iterations, true, payload) + } else { + enabled += runRepresentativePublish(iterations, true, payload) + disabled += runRepresentativePublish(iterations, false, payload) + } } overhead := float64(enabled-disabled) / float64(disabled) if overhead >= 0.05 { @@ -85,8 +95,9 @@ func BenchmarkObservabilityStandardOverhead(b *testing.B) { name = "standard-enabled" } b.Run(name, func(b *testing.B) { + payload := make([]byte, 1024*1024) for index := 0; index < b.N; index++ { - runRepresentativePublish(1, enabled) + runRepresentativePublish(1, enabled, payload) } }) } From 0e61dc2506e4470eddbf1f9ac0f73bd84473d24b Mon Sep 17 00:00:00 2001 From: yzimhao Date: Sun, 2 Aug 2026 19:09:52 +0800 Subject: [PATCH 3/4] Upgrade GitHub Actions workflow dependencies --- .github/workflows/ci.yml | 6 +++--- .github/workflows/integration.yml | 4 ++-- .github/workflows/pull-request.yml | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36dded5..d4d7900 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,10 +13,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: '1.24' cache: true @@ -25,7 +25,7 @@ jobs: run: go mod verify - name: Lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v9 with: version: latest diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index fa22e69..ffdadfc 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -16,10 +16,10 @@ jobs: timeout-minutes: 20 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: "1.24" cache: true diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 2ac20b6..50d076b 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -18,10 +18,10 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: "1.24" cache: true @@ -51,7 +51,7 @@ jobs: go run ./examples/observability -observe -metrics -correlation -events - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v9 with: version: latest @@ -61,10 +61,10 @@ jobs: timeout-minutes: 15 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: "1.24" cache: true From 47bdd3cc2dfd2cc7a5aa87cf2595b7f520954604 Mon Sep 17 00:00:00 2001 From: yzimhao Date: Sun, 2 Aug 2026 19:18:14 +0800 Subject: [PATCH 4/4] Update golangci-lint configuration and fix error handling --- .golangci.yml | 51 +++++++++++++++++---------------- benchmark_test.go | 2 +- brokers/kafka/kafka.go | 2 +- brokers/nats/nats.go | 2 +- brokers/pubsub/pubsub.go | 2 +- brokers/rabbitmq/rabbitmq.go | 2 +- brokers/redis/redis.go | 2 +- brokers/rocketmq/rocketmq.go | 2 +- brokers/sqs/sqs.go | 2 +- examples/basic/main.go | 2 +- examples/nats/main.go | 2 +- examples/observability/main.go | 18 ++++++------ examples/pubsub/main.go | 2 +- examples/rabbitmq/main.go | 2 +- examples/redis/main.go | 2 +- examples/rocketmq/main.go | 2 +- examples/sqs/main.go | 2 +- observability_benchmark_test.go | 2 +- observability_runtime.go | 2 +- 19 files changed, 53 insertions(+), 50 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 6152cb5..d0c83a9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,43 +1,46 @@ +version: "2" + run: timeout: 5m issues-exit-code: 1 tests: true + allow-parallel-runners: true linters: + default: none enable: - errcheck - - gosimple - staticcheck - unused - govet - ineffassign - - typecheck - -issues: - # Exclude some common false positives or errors that are acceptable in specific contexts - exclude-rules: - # 1. Allow ignoring errors in test files (test code is usually less strict) - - path: '_test\.go' - linters: - - errcheck - - staticcheck - text: "(SA1012|Error return value of .*(Init|Connect). is not checked)" + exclusions: + generated: lax + # Exclude some common false positives or errors that are acceptable in specific contexts + rules: + # 1. Allow ignoring errors in test files (test code is usually less strict) + - path: '_test\.go' + linters: + - errcheck + - staticcheck + text: "(SA1012|Error return value of .*(Init|Connect). is not checked)" - # 2. Ignore errcheck for specific methods where missing error check is common/acceptable - - linters: - - errcheck - text: 'Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Shutdown|.*Unsubscribe|.*Ack|.*Nack). is not checked' + # 2. Ignore errcheck for specific methods where missing error check is common/acceptable + - linters: + - errcheck + text: 'Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Shutdown|.*Unsubscribe|.*Ack|.*Nack). is not checked' - # 3. Ignore deprecation warnings for now (planned for future upgrades) - - linters: - - staticcheck - text: "SA1019" + # 3. Ignore deprecation warnings for now (planned for future upgrades) + - linters: + - staticcheck + text: "SA1019" - # 4. Ignore warnings about using built-in string type as context keys (to be fixed later) - - linters: - - staticcheck - text: "SA1029" + # 4. Ignore warnings about using built-in string type as context keys (to be fixed later) + - linters: + - staticcheck + text: "SA1029" +issues: # Set max issues to 0 to show all findings and avoid suppression during initial setup max-issues-per-linter: 0 max-same-issues: 0 diff --git a/benchmark_test.go b/benchmark_test.go index f283171..f2209fe 100644 --- a/benchmark_test.go +++ b/benchmark_test.go @@ -18,7 +18,7 @@ func BenchmarkNoopBrokerPublish(b *testing.B) { } func BenchmarkDirectInterface(b *testing.B) { - var broker Broker = NewNoopBroker() + broker := NewNoopBroker() topic := "bench" msg := &Message{Body: []byte("test")} ctx := context.Background() diff --git a/brokers/kafka/kafka.go b/brokers/kafka/kafka.go index c1f730c..75226b1 100644 --- a/brokers/kafka/kafka.go +++ b/brokers/kafka/kafka.go @@ -161,7 +161,7 @@ func (k *kafkaBroker) Disconnect() error { k.running = false k.Transition(broker.StateStopped, false, false, nil) - k.Runtime.Close() + k.Close() return nil } diff --git a/brokers/nats/nats.go b/brokers/nats/nats.go index e48c91d..755964a 100644 --- a/brokers/nats/nats.go +++ b/brokers/nats/nats.go @@ -151,7 +151,7 @@ func (n *natsBroker) Disconnect() error { n.running = false n.Transition(broker.StateStopped, false, false, nil) - n.Runtime.Close() + n.Close() return nil } diff --git a/brokers/pubsub/pubsub.go b/brokers/pubsub/pubsub.go index 395324f..2aeeff0 100644 --- a/brokers/pubsub/pubsub.go +++ b/brokers/pubsub/pubsub.go @@ -134,7 +134,7 @@ func (p *pubsubBroker) Disconnect() error { p.provider = nil p.running = false p.Transition(broker.StateStopped, false, false, nil) - p.Runtime.Close() + p.Close() return closeErr } diff --git a/brokers/rabbitmq/rabbitmq.go b/brokers/rabbitmq/rabbitmq.go index e09772d..df74de6 100644 --- a/brokers/rabbitmq/rabbitmq.go +++ b/brokers/rabbitmq/rabbitmq.go @@ -201,7 +201,7 @@ func (r *rmqBroker) Disconnect() error { r.running = false r.Transition(broker.StateStopped, false, false, nil) - r.Runtime.Close() + r.Close() return nil } diff --git a/brokers/redis/redis.go b/brokers/redis/redis.go index 2d55ac5..0f83f48 100644 --- a/brokers/redis/redis.go +++ b/brokers/redis/redis.go @@ -135,7 +135,7 @@ func (r *redisBroker) Disconnect() error { r.running = false r.Transition(broker.StateStopped, false, false, nil) - r.Runtime.Close() + r.Close() return nil } diff --git a/brokers/rocketmq/rocketmq.go b/brokers/rocketmq/rocketmq.go index c203d90..e3201be 100644 --- a/brokers/rocketmq/rocketmq.go +++ b/brokers/rocketmq/rocketmq.go @@ -152,7 +152,7 @@ func (r *rmqBroker) Disconnect() error { r.running = false r.Transition(broker.StateStopped, false, false, nil) - r.Runtime.Close() + r.Close() return nil } diff --git a/brokers/sqs/sqs.go b/brokers/sqs/sqs.go index d36fa83..bd7b219 100644 --- a/brokers/sqs/sqs.go +++ b/brokers/sqs/sqs.go @@ -100,7 +100,7 @@ func (s *sqsBroker) Disconnect() error { s.client = nil s.running = false s.Transition(broker.StateStopped, false, false, nil) - s.Runtime.Close() + s.Close() return nil } diff --git a/examples/basic/main.go b/examples/basic/main.go index e6da01c..185651e 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -17,7 +17,7 @@ func main() { if err := b.Connect(); err != nil { log.Fatalf("failed to connect: %v", err) } - defer b.Disconnect() + defer func() { _ = b.Disconnect() }() // Subscribe to a topic topic := "example.topic" diff --git a/examples/nats/main.go b/examples/nats/main.go index e17f568..82910ff 100644 --- a/examples/nats/main.go +++ b/examples/nats/main.go @@ -18,7 +18,7 @@ func main() { if err := b.Connect(); err != nil { log.Fatalf("Connect error: %v", err) } - defer b.Disconnect() + defer func() { _ = b.Disconnect() }() // Subscribe _, err := b.Subscribe("test_subject", func(ctx context.Context, event broker.Event) error { diff --git a/examples/observability/main.go b/examples/observability/main.go index c91ea15..9448b48 100644 --- a/examples/observability/main.go +++ b/examples/observability/main.go @@ -33,8 +33,8 @@ func (w *lockedWriter) Write(p []byte) (int, error) { } func (s printSink) HandleObservabilityEvent(_ context.Context, event broker.BrokerStateEvent) error { - fmt.Fprintf(s.writer, "event kind=%s operation=%s outcome=%s state=%s\n", event.Kind, event.Operation, event.Outcome, event.To) - return nil + _, err := fmt.Fprintf(s.writer, "event kind=%s operation=%s outcome=%s state=%s\n", event.Kind, event.Operation, event.Outcome, event.To) + return err } type scenarioBroker struct { @@ -145,7 +145,7 @@ func runTo(args []string, output io.Writer) error { b := broker.Broker(&scenarioBroker{Broker: base, runtime: runtime, scenario: cfg.scenario}) observable := b.(broker.Observable).Observability() if cfg.all || cfg.health { - fmt.Fprintf(output, "health state=%s ready=%t\n", observable.Health().State, observable.Health().Ready) + _, _ = fmt.Fprintf(output, "health state=%s ready=%t\n", observable.Health().State, observable.Health().Ready) } if err := b.Connect(); err != nil { if cfg.all || cfg.diagnostics { @@ -153,12 +153,12 @@ func runTo(args []string, output io.Writer) error { } return err } - defer b.Disconnect() + defer func() { _ = b.Disconnect() }() ctx, cancel := context.WithTimeout(context.Background(), cfg.timeout) defer cancel() if cfg.probe { status, err := observable.Probe(ctx) - fmt.Fprintf(output, "health probe state=%s ready=%t unsupported=%t\n", status.State, status.Ready, errors.Is(err, broker.ErrUnsupported)) + _, _ = fmt.Fprintf(output, "health probe state=%s ready=%t unsupported=%t\n", status.State, status.Ready, errors.Is(err, broker.ErrUnsupported)) } handler := func(context.Context, broker.Event) error { if cfg.scenario == "handler-failure" { @@ -179,15 +179,15 @@ func runTo(args []string, output io.Writer) error { } if cfg.all || cfg.metrics { total := observable.Snapshot().OperationTotals["publish:success"] - fmt.Fprintf(output, "metric name=broker.operations operation=publish outcome=success value=%d\n", total) + _, _ = fmt.Fprintf(output, "metric name=broker.operations operation=publish outcome=success value=%d\n", total) } if cfg.all || cfg.correlation { - fmt.Fprintln(output, "scenario correlation=enabled propagated=true") + _, _ = fmt.Fprintln(output, "scenario correlation=enabled propagated=true") } if cfg.all || cfg.diagnostics { printSnapshot(output, observable.Snapshot()) } - fmt.Fprintf(output, "scenario name=%s status=complete\n", cfg.scenario) + _, _ = fmt.Fprintf(output, "scenario name=%s status=complete\n", cfg.scenario) return nil } @@ -196,7 +196,7 @@ func printSnapshot(output io.Writer, snap broker.DiagnosticSnapshot) { if snap.LastFailure != nil { category = snap.LastFailure.Category } - fmt.Fprintf(output, "snapshot broker=%s state=%s subscriptions=%d dropped=%d error_category=%s\n", snap.BrokerSystem, snap.Health.State, snap.SubscriptionCount, snap.DroppedRecords, category) + _, _ = fmt.Fprintf(output, "snapshot broker=%s state=%s subscriptions=%d dropped=%d error_category=%s\n", snap.BrokerSystem, snap.Health.State, snap.SubscriptionCount, snap.DroppedRecords, category) } func main() { diff --git a/examples/pubsub/main.go b/examples/pubsub/main.go index 1fa838b..64262ff 100644 --- a/examples/pubsub/main.go +++ b/examples/pubsub/main.go @@ -23,7 +23,7 @@ func main() { if err := b.Connect(); err != nil { log.Fatalf("Broker Connect error: %v", err) } - defer b.Disconnect() + defer func() { _ = b.Disconnect() }() topic := "your-topic-id" subscription := "your-subscription-id" diff --git a/examples/rabbitmq/main.go b/examples/rabbitmq/main.go index 96cdb44..9b45a42 100644 --- a/examples/rabbitmq/main.go +++ b/examples/rabbitmq/main.go @@ -18,7 +18,7 @@ func main() { if err := b.Connect(); err != nil { log.Fatalf("Connect error: %v", err) } - defer b.Disconnect() + defer func() { _ = b.Disconnect() }() // Subscribe _, err := b.Subscribe("test_topic", func(ctx context.Context, event broker.Event) error { diff --git a/examples/redis/main.go b/examples/redis/main.go index 7df5b3d..92e3552 100644 --- a/examples/redis/main.go +++ b/examples/redis/main.go @@ -20,7 +20,7 @@ func main() { if err := b.Connect(); err != nil { log.Fatalf("Cant connect to redis: %v", err) } - defer b.Disconnect() + defer func() { _ = b.Disconnect() }() topic := "test_topic" diff --git a/examples/rocketmq/main.go b/examples/rocketmq/main.go index ccb145a..c058280 100644 --- a/examples/rocketmq/main.go +++ b/examples/rocketmq/main.go @@ -23,7 +23,7 @@ func main() { if err := b.Connect(); err != nil { log.Fatalf("Connect error: %v", err) } - defer b.Disconnect() + defer func() { _ = b.Disconnect() }() _, err := b.Subscribe("test_topic", func(ctx context.Context, event broker.Event) error { fmt.Printf("Received message: %s\n", string(event.Message().Body)) diff --git a/examples/sqs/main.go b/examples/sqs/main.go index 96ab53f..a0152f4 100644 --- a/examples/sqs/main.go +++ b/examples/sqs/main.go @@ -21,7 +21,7 @@ func main() { if err := b.Connect(); err != nil { log.Fatalf("Broker Connect error: %v", err) } - defer b.Disconnect() + defer func() { _ = b.Disconnect() }() // queueURL := "your-sqs-queue-url" queueURL := "your-sqs-queue-url" diff --git a/observability_benchmark_test.go b/observability_benchmark_test.go index 7071209..b9e0c32 100644 --- a/observability_benchmark_test.go +++ b/observability_benchmark_test.go @@ -51,7 +51,7 @@ func runRepresentativePublish(iterations int, enabled bool, payload []byte) time } candidate := NewNoopBroker(options...) _ = candidate.Connect() - defer candidate.Disconnect() + defer func() { _ = candidate.Disconnect() }() message := &Message{Body: payload} started := time.Now() for index := 0; index < iterations; index++ { diff --git a/observability_runtime.go b/observability_runtime.go index a6341cd..bb5abbf 100644 --- a/observability_runtime.go +++ b/observability_runtime.go @@ -248,7 +248,7 @@ func (r *Runtime) Observe(operation Operation, started time.Time, err error) { if r == nil { return } - if !r.enabled(CategoryDiagnostics) && !r.enabled(CategoryMeasurements) && !(err != nil && r.enabled(CategoryLogging)) { + if !r.enabled(CategoryDiagnostics) && !r.enabled(CategoryMeasurements) && (err == nil || !r.enabled(CategoryLogging)) { return } outcome := OutcomeSuccess