Add configuration for Code Review Automation and enhance .gitignore. Introduce .coderabbit.yml for automated reviews with profiles for correctness, maintainability, security, and performance. Update paths to include relevant directories and exclude generated files. Modify .gitignore to include coverage reports and generated files. Refactor Docker Compose to use updated paths for database initialization scripts. Update Go module dependencies and enhance Makefile with new code generation tasks. Transition domain models to a new DTO structure for better separation of concerns.

This commit is contained in:
windyboy
2025-11-17 11:47:23 +08:00
parent 12fda00df9
commit 204217015d
45 changed files with 252 additions and 175 deletions
+102
View File
@@ -0,0 +1,102 @@
package log
import "go.uber.org/zap"
// ErrorType represents a coarse-grained categorisation of errors for logging and alerting.
// Typical values:
// - business: validation failures, domain rule violations, payload issues.
// - transient: network / DB / NATS glitches that may succeed on retry.
// - fatal: programming bugs, schema mismatches, or conditions that require operator action.
type ErrorType string
const (
ErrorTypeBusiness ErrorType = "business"
ErrorTypeTransient ErrorType = "transient"
ErrorTypeFatal ErrorType = "fatal"
)
// Canonical logging field names for structured logs produced by the CAATSM
// receiver. Using constants avoids scattering magic strings and keeps log
// analysis queries stable over time.
const (
FieldService = "service"
FieldTransportMsgID = "transport_msg_id"
FieldTelegramMsgID = "telegram_message_id"
FieldCategory = "category"
FieldStream = "stream"
FieldConsumer = "consumer"
FieldSubject = "subject"
FieldNATSSequence = "nats_sequence"
FieldRequestID = "request_id"
FieldTraceID = "trace_id"
FieldErrorType = "error_type"
)
// MessageFields captures the common structured logging contract for message-processing logs.
// All fields are optional; empty values will simply be skipped.
type MessageFields struct {
// Identifiers
Service string
TransportMsgID string
BusinessMsgID string
Category string
// NATS / JetStream context
Stream string
Consumer string
Subject string
JSSequence uint64
// Correlation / tracing
RequestID string
TraceID string
// Error classification
ErrorType ErrorType
}
// WithMessageContext returns a logger pre-populated with the structured fields defined in MessageFields.
// This is the primary entry point for enforcing the logging contract in the codebase.
func WithMessageContext(logger *zap.Logger, mf MessageFields) *zap.Logger {
if logger == nil {
return zap.NewNop()
}
fields := make([]zap.Field, 0, 12)
if mf.Service != "" {
fields = append(fields, zap.String(FieldService, mf.Service))
}
if mf.TransportMsgID != "" {
fields = append(fields, zap.String(FieldTransportMsgID, mf.TransportMsgID))
}
if mf.BusinessMsgID != "" {
fields = append(fields, zap.String(FieldTelegramMsgID, mf.BusinessMsgID))
}
if mf.Category != "" {
fields = append(fields, zap.String(FieldCategory, mf.Category))
}
if mf.Stream != "" {
fields = append(fields, zap.String(FieldStream, mf.Stream))
}
if mf.Consumer != "" {
fields = append(fields, zap.String(FieldConsumer, mf.Consumer))
}
if mf.Subject != "" {
fields = append(fields, zap.String(FieldSubject, mf.Subject))
}
if mf.JSSequence > 0 {
fields = append(fields, zap.Uint64(FieldNATSSequence, mf.JSSequence))
}
if mf.RequestID != "" {
fields = append(fields, zap.String(FieldRequestID, mf.RequestID))
}
if mf.TraceID != "" {
fields = append(fields, zap.String(FieldTraceID, mf.TraceID))
}
if mf.ErrorType != "" {
fields = append(fields, zap.String(FieldErrorType, string(mf.ErrorType)))
}
return logger.With(fields...)
}
+252
View File
@@ -0,0 +1,252 @@
package metrics
import (
"math"
"net/http"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// Metric and label key/value contracts for the CAATSM receiver. Centralising these
// names avoids scattering magic strings across the codebase and keeps PromQL and
// documentation aligned with the implementation.
const (
// Metric names.
MetricProcessedTotal = "caatsm_processed_total"
MetricFailuresTotal = "caatsm_failures_total"
MetricParseLatencySeconds = "caatsm_parse_latency_seconds"
MetricMessagesTotal = "caatsm_messages_total"
MetricHandleLatencySeconds = "caatsm_handle_latency_seconds"
MetricRetriesTotal = "caatsm_retries_total"
MetricJSAPICallsTotal = "caatsm_js_api_calls_total"
MetricDBQueriesTotal = "caatsm_db_queries_total"
MetricDBQueryLatencySeconds = "caatsm_db_query_latency_seconds"
MetricDLQMessagesTotal = "caatsm_dlq_messages_total"
MetricDLQPublishFailures = "caatsm_dlq_publish_failures_total"
MetricNATSConsumerPending = "caatsm_nats_consumer_pending_messages"
// Common label keys.
LabelStatus = "status"
LabelCategory = "category"
LabelStage = "stage"
LabelStream = "stream"
LabelConsumer = "consumer"
LabelResult = "result"
LabelReason = "reason"
LabelOperation = "operation"
// Standard result label values for caatsm_messages_total.
ResultOK = "ok"
ResultFail = "fail"
ResultPermanentFail = "permanent_fail"
// Standard result values for DB operations.
DBResultOK = "ok"
DBResultError = "error"
// Standard retry reasons.
RetryReasonProcessorError = "processor_error"
)
var (
once sync.Once
registry *prometheus.Registry
// Legacy metrics (kept for backward compatibility).
processedCounter *prometheus.CounterVec
failureCounter *prometheus.CounterVec
parseLatency *prometheus.HistogramVec
// Message handling metrics (per stream / consumer).
messagesTotal *prometheus.CounterVec
handleLatency *prometheus.HistogramVec
retriesTotal *prometheus.CounterVec
jsAPICallsTotal *prometheus.CounterVec
dlqMessagesTotal *prometheus.CounterVec
dlqPublishFailures *prometheus.CounterVec
// Database metrics.
dbQueriesTotal *prometheus.CounterVec
dbQueryLatency *prometheus.HistogramVec
// NATS consumer lag metrics.
natsConsumerPending *prometheus.GaugeVec
)
func initCollectors() {
registry = prometheus.NewRegistry()
// Legacy metrics.
processedCounter = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: MetricProcessedTotal,
Help: "Count of telegrams processed by status and category.",
}, []string{LabelStatus, LabelCategory})
failureCounter = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: MetricFailuresTotal,
Help: "Count of processor failures by stage (parser, repository, publisher).",
}, []string{LabelStage})
parseLatency = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: MetricParseLatencySeconds,
Help: "Latency between reception and parse completion.",
Buckets: prometheus.DefBuckets,
}, []string{LabelStatus, LabelCategory})
// New message handling metrics.
messagesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: MetricMessagesTotal,
Help: "Total number of messages handled by the receiver, labelled by stream, consumer and result.",
}, []string{LabelStream, LabelConsumer, LabelResult})
handleLatency = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: MetricHandleLatencySeconds,
Help: "Latency of end-to-end message handling in seconds, from NATS receive to handler completion.",
Buckets: prometheus.DefBuckets,
}, []string{LabelStream, LabelConsumer})
retriesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: MetricRetriesTotal,
Help: "Total number of message retries (negative acknowledgements), labelled by stream, consumer and reason.",
}, []string{LabelStream, LabelConsumer, LabelReason})
dlqMessagesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: MetricDLQMessagesTotal,
Help: "Total number of messages routed to the DLQ, labelled by stream and consumer.",
}, []string{LabelStream, LabelConsumer})
dlqPublishFailures = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: MetricDLQPublishFailures,
Help: "Total number of failures when publishing to the DLQ, labelled by stream and consumer.",
}, []string{LabelStream, LabelConsumer})
jsAPICallsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: MetricJSAPICallsTotal,
Help: "Count of JetStream API calls made by the receiver.",
}, []string{LabelOperation})
// Database metrics.
dbQueriesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: MetricDBQueriesTotal,
Help: "Total number of database operations, labelled by operation and result.",
}, []string{LabelOperation, LabelResult})
dbQueryLatency = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: MetricDBQueryLatencySeconds,
Help: "Latency of database operations in seconds, labelled by operation.",
Buckets: prometheus.DefBuckets,
}, []string{LabelOperation})
natsConsumerPending = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: MetricNATSConsumerPending,
Help: "Approximate number of pending messages for a JetStream consumer, labelled by stream and consumer.",
}, []string{LabelStream, LabelConsumer})
registry.MustRegister(
processedCounter,
failureCounter,
parseLatency,
messagesTotal,
handleLatency,
retriesTotal,
jsAPICallsTotal,
dlqMessagesTotal,
dlqPublishFailures,
dbQueriesTotal,
dbQueryLatency,
natsConsumerPending,
)
}
func ensureCollectors() {
once.Do(initCollectors)
}
// Handler exposes the Prometheus metrics registry.
func Handler() http.Handler {
ensureCollectors()
return promhttp.HandlerFor(registry, promhttp.HandlerOpts{})
}
// RecordProcessed tracks the final status of a telegram along with the parse latency.
func RecordProcessed(status, category string, elapsed time.Duration) {
ensureCollectors()
processedCounter.WithLabelValues(labelValue(status), labelValue(category)).Inc()
seconds := math.Max(elapsed.Seconds(), 0)
parseLatency.WithLabelValues(labelValue(status), labelValue(category)).Observe(seconds)
}
// RecordFailure increments the failure counter for the supplied stage.
func RecordFailure(stage string) {
ensureCollectors()
failureCounter.WithLabelValues(labelValue(stage)).Inc()
}
// RecordMessageHandled records end-to-end message handling metrics (per stream / consumer).
// Result is expected to be values such as "ok", "fail", or "retry".
func RecordMessageHandled(stream, consumer, result string, elapsed time.Duration) {
ensureCollectors()
if elapsed < 0 {
elapsed = 0
}
messagesTotal.WithLabelValues(labelValue(stream), labelValue(consumer), labelValue(result)).Inc()
handleLatency.WithLabelValues(labelValue(stream), labelValue(consumer)).Observe(elapsed.Seconds())
}
// RecordRetry increments the retry counter for a message that is being negatively acknowledged.
// Reason can capture the high-level cause, e.g. "processor_error" or "nats_timeout".
func RecordRetry(stream, consumer, reason string) {
ensureCollectors()
retriesTotal.WithLabelValues(labelValue(stream), labelValue(consumer), labelValue(reason)).Inc()
}
// RecordDLQMessage increments the DLQ message counter for a successfully routed message.
func RecordDLQMessage(stream, consumer string) {
ensureCollectors()
dlqMessagesTotal.WithLabelValues(labelValue(stream), labelValue(consumer)).Inc()
}
// RecordDLQPublishFailure increments the DLQ publish failure counter when a DLQ
// publish attempt fails.
func RecordDLQPublishFailure(stream, consumer string) {
ensureCollectors()
dlqPublishFailures.WithLabelValues(labelValue(stream), labelValue(consumer)).Inc()
}
// RecordDBQuery records metrics for a single database operation.
// Operation examples: "insert_one", "insert_batch", "insert_raw".
// Result is usually "ok" or "error".
func RecordDBQuery(operation, result string, elapsed time.Duration) {
ensureCollectors()
if elapsed < 0 {
elapsed = 0
}
dbQueriesTotal.WithLabelValues(labelValue(operation), labelValue(result)).Inc()
dbQueryLatency.WithLabelValues(labelValue(operation)).Observe(elapsed.Seconds())
}
// RecordJSAPICall increments the JetStream API call counter for the given operation.
func RecordJSAPICall(operation string) {
ensureCollectors()
jsAPICallsTotal.WithLabelValues(labelValue(operation)).Inc()
}
// RecordNATSConsumerPending records the current pending message count for a
// JetStream consumer as a gauge, enabling backlog / lag alerts.
func RecordNATSConsumerPending(stream, consumer string, pending uint64) {
ensureCollectors()
natsConsumerPending.WithLabelValues(labelValue(stream), labelValue(consumer)).Set(float64(pending))
}
func labelValue(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "unknown"
}
return strings.ToLower(value)
}
+1 -1
View File
@@ -3,7 +3,7 @@ package monitoring
import (
"caatsm/internal/infra/buildinfo"
"caatsm/internal/infra/config"
obsmetrics "caatsm/internal/observability/metrics"
obsmetrics "caatsm/internal/infra/metrics"
"context"
"encoding/json"
"errors"
+4 -4
View File
@@ -3,9 +3,9 @@ package nats
import (
"caatsm/internal/app"
"caatsm/internal/infra/config"
obslogging "caatsm/internal/observability/logging"
obsmetrics "caatsm/internal/observability/metrics"
"caatsm/internal/observability/telemetry"
"caatsm/internal/infra/log"
obsmetrics "caatsm/internal/infra/metrics"
"caatsm/internal/infra/telemetry"
"context"
"encoding/json"
"errors"
@@ -792,7 +792,7 @@ func (c *Consumer) processMessage(ctx context.Context, msg *nats.Msg) error {
)
}
msgLogger := obslogging.WithMessageContext(c.logger, obslogging.MessageFields{
msgLogger := log.WithMessageContext(c.logger, log.MessageFields{
Service: "caatsm-consumer",
TransportMsgID: msgID,
Stream: c.streamName,
+4 -4
View File
@@ -1,9 +1,9 @@
package nats
import (
"caatsm/internal/adapter"
"caatsm/internal/infra/config"
"caatsm/internal/model"
"caatsm/internal/adapter/dto"
"caatsm/internal/port"
"encoding/json"
"errors"
"fmt"
@@ -25,7 +25,7 @@ func ProvidePublisher(
js nats.JetStreamContext,
cfg *config.Config,
logger *zap.Logger,
) (adapter.Publisher, error) {
) (port.Publisher, error) {
return &Publisher{
js: js,
cfg: cfg,
@@ -51,7 +51,7 @@ func (p *Publisher) Publish(message interface{}) error {
jsMsg.Data = messageBytes
switch typed := message.(type) {
case *model.ParsedTelegram:
case *dto.ParsedTelegram:
if typed != nil && typed.Uuid != "" {
jsMsg.Header.Set("Nats-Msg-Id", typed.Uuid)
} else {
+8 -8
View File
@@ -1,10 +1,10 @@
package postgres
import (
"caatsm/internal/adapter"
"caatsm/internal/adapter/mapper"
"caatsm/internal/model"
obsmetrics "caatsm/internal/observability/metrics"
"caatsm/internal/adapter/dto"
"caatsm/internal/port"
obsmetrics "caatsm/internal/infra/metrics"
"context"
"encoding/json"
"fmt"
@@ -19,7 +19,7 @@ import (
"go.uber.org/zap"
)
// Repository implements the adapter.Repository interface using PostgreSQL
// Repository implements the port.Repository interface using PostgreSQL
type Repository struct {
pool *pgxpool.Pool
mapper *mapper.TelegramMapper
@@ -27,7 +27,7 @@ type Repository struct {
}
// ProvideRepository creates a PostgreSQL repository
func ProvideRepository(pool *pgxpool.Pool, logger *zap.Logger) (adapter.Repository, error) {
func ProvideRepository(pool *pgxpool.Pool, logger *zap.Logger) (port.Repository, error) {
return &Repository{
pool: pool,
mapper: mapper.NewTelegramMapper(),
@@ -36,7 +36,7 @@ func ProvideRepository(pool *pgxpool.Pool, logger *zap.Logger) (adapter.Reposito
}
// InsertOne inserts a single telegram message
func (r *Repository) InsertOne(ctx context.Context, msg *model.ParsedTelegram) error {
func (r *Repository) InsertOne(ctx context.Context, msg *dto.ParsedTelegram) error {
ctx, span := otel.Tracer("caatsm/postgres").Start(ctx, "Repository.InsertOne")
defer span.End()
span.SetAttributes(attribute.String("db.table", "aviation.telegrams"))
@@ -114,7 +114,7 @@ func (r *Repository) InsertOne(ctx context.Context, msg *model.ParsedTelegram) e
}
// InsertBatch inserts multiple telegram messages in a batch using CopyFrom
func (r *Repository) InsertBatch(ctx context.Context, msgs []*model.ParsedTelegram) error {
func (r *Repository) InsertBatch(ctx context.Context, msgs []*dto.ParsedTelegram) error {
ctx, span := otel.Tracer("caatsm/postgres").Start(ctx, "Repository.InsertBatch")
defer span.End()
span.SetAttributes(attribute.String("db.table", "aviation.telegrams"))
@@ -169,7 +169,7 @@ func (r *Repository) InsertBatch(ctx context.Context, msgs []*model.ParsedTelegr
}
// InsertRaw inserts a failed telegram into aviation.telegrams_raw for post-processing.
func (r *Repository) InsertRaw(ctx context.Context, msg *model.ParsedTelegram) error {
func (r *Repository) InsertRaw(ctx context.Context, msg *dto.ParsedTelegram) error {
ctx, span := otel.Tracer("caatsm/postgres").Start(ctx, "Repository.InsertRaw")
defer span.End()
span.SetAttributes(attribute.String("db.table", "aviation.telegrams_raw"))
+44
View File
@@ -0,0 +1,44 @@
CREATE SCHEMA IF NOT EXISTS aviation;
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE TABLE aviation.telegrams (
uuid UUID NOT NULL,
message_id TEXT,
date_time TEXT,
priority_indicator TEXT,
primary_address TEXT,
secondary_addresses TEXT,
originator TEXT,
originator_date_time TEXT,
category TEXT,
content TEXT,
body_data JSONB,
status TEXT NOT NULL DEFAULT 'parsed',
error_reason TEXT,
received_at TIMESTAMPTZ NOT NULL,
parsed_at TIMESTAMPTZ,
dispatched_at TIMESTAMPTZ,
need_dispatch BOOLEAN,
PRIMARY KEY (uuid, received_at)
);
SELECT create_hypertable('aviation.telegrams', 'received_at', if_not_exists => TRUE);
-- Indexes for better query performance
CREATE INDEX idx_telegrams_message_id ON aviation.telegrams (message_id);
CREATE INDEX idx_telegrams_date_time ON aviation.telegrams (date_time);
CREATE INDEX idx_telegrams_priority_indicator ON aviation.telegrams (priority_indicator);
CREATE INDEX idx_telegrams_primary_address ON aviation.telegrams (primary_address);
CREATE INDEX idx_telegrams_received_at ON aviation.telegrams (received_at);
CREATE INDEX idx_telegrams_uuid ON aviation.telegrams (uuid);
CREATE TABLE IF NOT EXISTS aviation.telegrams_raw (
uuid UUID NOT NULL,
status TEXT NOT NULL,
error_reason TEXT,
content TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
metadata JSONB,
PRIMARY KEY (uuid, received_at)
);
+307
View File
@@ -0,0 +1,307 @@
package telemetry
import (
"caatsm/internal/infra/config"
obsmetrics "caatsm/internal/infra/metrics"
"context"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// Recorder provides a thin abstraction over telemetry backends (OpenTelemetry,
// Prometheus, etc.) so that application code does not need to import concrete
// metric libraries directly.
type Recorder interface {
// RecordProcessingResult captures the final processing status of a telegram
// along with the parser latency.
RecordProcessingResult(ctx context.Context, status, category string, parseLatency time.Duration)
// RecordPublishFailure increments the publish failure counter for the given
// category.
RecordPublishFailure(ctx context.Context, category string)
// RecordFailure records a high-level failure bucket (parser, repository,
// publisher, etc.).
RecordFailure(stage string)
// RecordMessageHandled tracks end-to-end message handling for a particular
// stream/consumer pair.
RecordMessageHandled(ctx context.Context, stream, consumer, result string, elapsed time.Duration)
// RecordRetry records a retry (negative acknowledgement) reason.
RecordRetry(ctx context.Context, stream, consumer, reason string)
// RecordDLQMessage records a successfully routed DLQ message.
RecordDLQMessage(ctx context.Context, stream, consumer string)
// RecordDLQPublishFailure records a DLQ publish failure.
RecordDLQPublishFailure(ctx context.Context, stream, consumer string)
// RecordJSAPICall records a JetStream API call.
RecordJSAPICall(operation string)
}
// ProvideRecorder wires a composite Recorder based on configuration flags.
// - When telemetry is enabled, an OpenTelemetry-backed recorder is included.
// - When metrics are enabled, a Prometheus-backed recorder is included.
// - When neither is enabled, a noop recorder is returned.
func ProvideRecorder(cfg *config.Config) Recorder {
if cfg == nil {
return NewNoop()
}
var recorders []Recorder
if cfg.Telemetry.Enabled {
recorders = append(recorders, newOTelRecorder())
}
if !cfg.Monitoring.Disabled && cfg.Monitoring.EnableMetrics {
recorders = append(recorders, newPromRecorder())
}
if len(recorders) == 0 {
return NewNoop()
}
return NewComposite(recorders...)
}
// noopRecorder implements Recorder but performs no operations.
type noopRecorder struct{}
func NewNoop() Recorder {
return &noopRecorder{}
}
func (n *noopRecorder) RecordProcessingResult(ctx context.Context, status, category string, parseLatency time.Duration) {
}
func (n *noopRecorder) RecordPublishFailure(ctx context.Context, category string) {
}
func (n *noopRecorder) RecordFailure(stage string) {
}
func (n *noopRecorder) RecordMessageHandled(ctx context.Context, stream, consumer, result string, elapsed time.Duration) {
}
func (n *noopRecorder) RecordRetry(ctx context.Context, stream, consumer, reason string) {
}
func (n *noopRecorder) RecordDLQMessage(ctx context.Context, stream, consumer string) {
}
func (n *noopRecorder) RecordDLQPublishFailure(ctx context.Context, stream, consumer string) {
}
func (n *noopRecorder) RecordJSAPICall(operation string) {
}
// compositeRecorder fans out all calls to a slice of underlying recorders.
type compositeRecorder struct {
recorders []Recorder
}
func NewComposite(recorders ...Recorder) Recorder {
// Filter out nils defensively.
var filtered []Recorder
for _, r := range recorders {
if r != nil {
filtered = append(filtered, r)
}
}
if len(filtered) == 0 {
return NewNoop()
}
return &compositeRecorder{recorders: filtered}
}
func (c *compositeRecorder) RecordProcessingResult(ctx context.Context, status, category string, parseLatency time.Duration) {
for _, r := range c.recorders {
r.RecordProcessingResult(ctx, status, category, parseLatency)
}
}
func (c *compositeRecorder) RecordPublishFailure(ctx context.Context, category string) {
for _, r := range c.recorders {
r.RecordPublishFailure(ctx, category)
}
}
func (c *compositeRecorder) RecordFailure(stage string) {
for _, r := range c.recorders {
r.RecordFailure(stage)
}
}
func (c *compositeRecorder) RecordMessageHandled(ctx context.Context, stream, consumer, result string, elapsed time.Duration) {
for _, r := range c.recorders {
r.RecordMessageHandled(ctx, stream, consumer, result, elapsed)
}
}
func (c *compositeRecorder) RecordRetry(ctx context.Context, stream, consumer, reason string) {
for _, r := range c.recorders {
r.RecordRetry(ctx, stream, consumer, reason)
}
}
func (c *compositeRecorder) RecordDLQMessage(ctx context.Context, stream, consumer string) {
for _, r := range c.recorders {
r.RecordDLQMessage(ctx, stream, consumer)
}
}
func (c *compositeRecorder) RecordDLQPublishFailure(ctx context.Context, stream, consumer string) {
for _, r := range c.recorders {
r.RecordDLQPublishFailure(ctx, stream, consumer)
}
}
func (c *compositeRecorder) RecordJSAPICall(operation string) {
for _, r := range c.recorders {
r.RecordJSAPICall(operation)
}
}
// promRecorder delegates to the Prometheus metrics helpers in the
// internal/infra/metrics package.
type promRecorder struct{}
func newPromRecorder() Recorder {
return &promRecorder{}
}
func (p *promRecorder) RecordProcessingResult(ctx context.Context, status, category string, parseLatency time.Duration) {
if parseLatency < 0 {
parseLatency = 0
}
obsmetrics.RecordProcessed(status, category, parseLatency)
}
func (p *promRecorder) RecordPublishFailure(ctx context.Context, category string) {
// Prometheus metrics currently only expose failures via caatsm_failures_total,
// so we record the publisher failure there.
obsmetrics.RecordFailure("publisher")
}
func (p *promRecorder) RecordFailure(stage string) {
obsmetrics.RecordFailure(stage)
}
func (p *promRecorder) RecordMessageHandled(ctx context.Context, stream, consumer, result string, elapsed time.Duration) {
obsmetrics.RecordMessageHandled(stream, consumer, result, elapsed)
}
func (p *promRecorder) RecordRetry(ctx context.Context, stream, consumer, reason string) {
obsmetrics.RecordRetry(stream, consumer, reason)
}
func (p *promRecorder) RecordDLQMessage(ctx context.Context, stream, consumer string) {
obsmetrics.RecordDLQMessage(stream, consumer)
}
func (p *promRecorder) RecordDLQPublishFailure(ctx context.Context, stream, consumer string) {
obsmetrics.RecordDLQPublishFailure(stream, consumer)
}
func (p *promRecorder) RecordJSAPICall(operation string) {
obsmetrics.RecordJSAPICall(operation)
}
// otelRecorder creates and records OpenTelemetry metrics for the CAATSM
// processor. It intentionally focuses on a small set of high-value metrics to
// avoid duplicating the full Prometheus surface.
type otelRecorder struct {
meter metric.Meter
messageStatusAttrKey attribute.Key
messageCategoryAttrKey attribute.Key
messageProcessedCounter metric.Int64Counter
messagePublishFailCounter metric.Int64Counter
parseLatencyHistogram metric.Float64Histogram
}
func newOTelRecorder() Recorder {
meter := otel.Meter("caatsm/app")
statusKey := attribute.Key("message.status")
categoryKey := attribute.Key("message.category")
messageProcessedCounter, _ := meter.Int64Counter(
"caatsm_messages_processed_total",
metric.WithDescription("Total number of telegrams processed by the CAATSM processor."),
)
messagePublishFailCounter, _ := meter.Int64Counter(
"caatsm_publish_failures_total",
metric.WithDescription("Total number of telegram publish failures."),
)
parseLatencyHistogram, _ := meter.Float64Histogram(
"caatsm_parse_duration_seconds",
metric.WithDescription("Latency of parsing a telegram, in seconds."),
metric.WithUnit("s"),
)
return &otelRecorder{
meter: meter,
messageStatusAttrKey: statusKey,
messageCategoryAttrKey: categoryKey,
messageProcessedCounter: messageProcessedCounter,
messagePublishFailCounter: messagePublishFailCounter,
parseLatencyHistogram: parseLatencyHistogram,
}
}
func (o *otelRecorder) RecordProcessingResult(ctx context.Context, status, category string, parseLatency time.Duration) {
if parseLatency < 0 {
parseLatency = 0
}
o.messageProcessedCounter.Add(ctx, 1,
metric.WithAttributes(
o.messageStatusAttrKey.String(status),
o.messageCategoryAttrKey.String(category),
),
)
o.parseLatencyHistogram.Record(ctx, parseLatency.Seconds(),
metric.WithAttributes(
o.messageStatusAttrKey.String(status),
o.messageCategoryAttrKey.String(category),
),
)
}
func (o *otelRecorder) RecordPublishFailure(ctx context.Context, category string) {
o.messagePublishFailCounter.Add(ctx, 1,
metric.WithAttributes(
o.messageCategoryAttrKey.String(category),
),
)
}
func (o *otelRecorder) RecordFailure(stage string) {
// OpenTelemetry does not currently publish a dedicated failure counter; the
// Prometheus surface captures this. This method is a no-op here.
}
func (o *otelRecorder) RecordMessageHandled(ctx context.Context, stream, consumer, result string, elapsed time.Duration) {
// High-cardinality stream/consumer labels are exposed via Prometheus
// metrics; OTEL can rely on traces and existing consumer metrics.
}
func (o *otelRecorder) RecordRetry(ctx context.Context, stream, consumer, reason string) {
}
func (o *otelRecorder) RecordDLQMessage(ctx context.Context, stream, consumer string) {
}
func (o *otelRecorder) RecordDLQPublishFailure(ctx context.Context, stream, consumer string) {
}
func (o *otelRecorder) RecordJSAPICall(operation string) {
}