feat(telemetry): add NewConsole — logz-backed OTel exporters for local dev (v1.1.0)
NewConsole bootstraps the OTel SDK with three logz-backed exporters: - Trace: WithSyncer, one log line per closed span (immediate, no batch) - Metric: PeriodicReader (60s), flushed on shutdown - OTel log: BatchProcessor, for third-party libs using OTel log API ConsoleConfig requires only ServiceName — no OTLP endpoint needed. Adds logz v1.0.1 as direct dependency; module tier bumped 1 → 2.
This commit is contained in:
22
CHANGELOG.md
22
CHANGELOG.md
@@ -5,6 +5,28 @@ All notable changes to this module will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.1.0] - 2026-05-12
|
||||
|
||||
### Added
|
||||
|
||||
- `ConsoleConfig` — service identity configuration for console/dev mode (only `ServiceName`,
|
||||
`ServiceVersion`, `Environment`; no OTLP endpoint required)
|
||||
- `NewConsole(ctx, logger logz.Logger, cfg ConsoleConfig) (func(context.Context) error, error)` —
|
||||
bootstraps the OTel SDK with logz-backed exporters for local development. Traces are exported
|
||||
synchronously via `WithSyncer` (one log line per closed span); metrics via `PeriodicReader`
|
||||
(flushed on shutdown); OTel log records via `BatchProcessor`. No collector needed.
|
||||
- `logz v1.0.1` added as direct dependency (tier bumped from 1 → 2).
|
||||
|
||||
### Output format
|
||||
|
||||
```
|
||||
INFO otel: span name=GET /api/v1/permisos trace_id=3f2a9c... duration_ms=18 status=Ok
|
||||
INFO otel: metric name=http.server.duration kind=histogram count=5 sum=87.3 unit=ms
|
||||
INFO otel: log body=cache miss severity=INFO
|
||||
```
|
||||
|
||||
[1.1.0]: https://code.nochebuena.dev/go/telemetry/compare/v1.0.0...v1.1.0
|
||||
|
||||
## [1.0.0] - 2026-05-12
|
||||
|
||||
### Changed
|
||||
|
||||
36
CLAUDE.md
36
CLAUDE.md
@@ -8,20 +8,22 @@ Sets the three OTel global providers so that all micro-libs using the OTel globa
|
||||
|
||||
## Tier & Dependencies
|
||||
|
||||
**Tier 1** (no micro-lib dependencies; external OTel SDK only). Must never be imported by framework libraries.
|
||||
**Tier 2** — depends on Tier 1 `logz` (required by `NewConsole`). Must never be imported
|
||||
by framework libraries.
|
||||
|
||||
Depends on:
|
||||
- `code.nochebuena.dev/go/logz` (Tier 1) — used by `NewConsole` exporters
|
||||
- `go.opentelemetry.io/otel` and sub-packages — API and SDK
|
||||
- `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc`
|
||||
- `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc`
|
||||
- `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc`
|
||||
- `go.opentelemetry.io/otel/sdk/trace`, `.../metric`, `.../log`
|
||||
|
||||
No micro-lib dependencies. No `launcher` dependency — telemetry has no Component lifecycle.
|
||||
No `launcher` dependency — telemetry has no Component lifecycle.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- **Tier 1 / app-only** (ADR-001): Libraries use only the OTel API (no-op default). This module activates the real SDK. Importing it from a library is a mistake.
|
||||
- **Tier 2 / app-only** (ADR-001): Libraries use only the OTel API (no-op default). This module activates the real SDK. Importing it from a library is a mistake.
|
||||
- **Three-signal OTLP bootstrap** (ADR-002): `New(ctx, cfg)` sets up traces → Tempo, metrics → Mimir, logs → Loki, all over a single OTLP gRPC endpoint. W3C TraceContext + Baggage propagation is set globally.
|
||||
- **Global provider strategy** (ADR-003): Libraries call `otel.Tracer(...)` / `otel.Meter(...)` / `global.Logger(...)`. After `telemetry.New`, those calls route to the real SDK with no library changes required.
|
||||
- **No `launcher.Component`**: Telemetry is not a lifecycle component. The caller defers the returned shutdown function directly in `main`. This keeps the module dependency graph minimal and the interface simple.
|
||||
@@ -50,6 +52,23 @@ func main() {
|
||||
}
|
||||
```
|
||||
|
||||
**Local development — console mode (no collector required):**
|
||||
|
||||
```go
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
logger := logz.New(logz.Options{})
|
||||
shutdown, err := telemetry.NewConsole(ctx, logger, telemetry.ConsoleConfig{
|
||||
ServiceName: "order-service",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("telemetry: %v", err)
|
||||
}
|
||||
defer shutdown(ctx)
|
||||
// Traces, metrics, and OTel log records appear as logz log lines.
|
||||
}
|
||||
```
|
||||
|
||||
**With launcher (wire shutdown into lifecycle):**
|
||||
|
||||
```go
|
||||
@@ -73,14 +92,15 @@ lc.BeforeStop(func() error { return shutdown(ctx) })
|
||||
## What to Avoid
|
||||
|
||||
- Do not import this module from any non-`main` package. Libraries must use only OTel API packages.
|
||||
- Do not call `telemetry.New` more than once per process. Each call overwrites the global providers.
|
||||
- Do not call `telemetry.New` or `telemetry.NewConsole` more than once per process. Each call overwrites the global providers.
|
||||
- Do not omit the `defer shutdown(ctx)`. Without it, buffered spans and metrics are lost on exit.
|
||||
- Do not use a zero-value `Config`. Both `ServiceName` and `OTLPEndpoint` are required; `New` will return an error if the OTLP connection cannot be established.
|
||||
- Do not wrap this in a `launcher.Component`. The shutdown function pattern is simpler and avoids adding a `launcher` dependency to this module.
|
||||
- Do not wire the OTel slog bridge alongside `NewConsole`. The bridge routes logz/slog output through the OTel log API, which `logLogExporter` forwards back to logz — creating a feedback loop.
|
||||
|
||||
## Testing Notes
|
||||
|
||||
- The test file (`telemetry_test.go`) uses a `fakeCollector` that opens a TCP listener but speaks no gRPC protocol. This is sufficient to test that `New` succeeds and returns a callable shutdown function — the fake server accepts connections so the gRPC dial does not get connection-refused.
|
||||
- Tests that verify global provider replacement (`TestNew_SetsGlobalTracerProvider`, `TestNew_SetsGlobalMeterProvider`) must call `shutdown` in a `t.Cleanup` to restore state for subsequent tests. The short shutdown timeout (200ms) is intentional — the fake server cannot complete a gRPC flush, so errors from `shutdown(ctx)` are expected and ignored.
|
||||
- `newResource` is tested separately (`TestNewResource_Fields`, `TestNewResource_MergesWithDefault`) as a pure function with no I/O.
|
||||
- Do not test against a real Alloy or Tempo instance in unit tests. Use the fake collector pattern.
|
||||
- `telemetry_test.go` — uses a `fakeCollector` TCP listener to avoid connection-refused; tests `New` and the global provider assignments. Shutdown timeout 200ms is intentional since the fake server cannot complete a gRPC flush.
|
||||
- `console_test.go` — uses a `stubLogger` that captures log calls. `TestNewConsole_ExportsSpan` uses `WithSyncer` behavior: span export is synchronous, so the log is recorded immediately on `span.End()`. `TestNewConsole_ExportsMetric` triggers export via shutdown flush (PeriodicReader flushes on Shutdown).
|
||||
- `newResource` is tested separately as a pure function with no I/O.
|
||||
- Do not test against a real Alloy or Tempo instance in unit tests.
|
||||
|
||||
12
COMMIT.md
12
COMMIT.md
@@ -1,5 +1,9 @@
|
||||
feat(telemetry)!: promote to v1.0.0 — named shutdown errors per provider, Go 1.26
|
||||
feat(telemetry): add NewConsole — logz-backed OTel exporters for local dev (v1.1.0)
|
||||
|
||||
Label each provider shutdown failure with its signal name (trace/metric/log).
|
||||
Errors remain joined via errors.Join; individual causes still unwrappable.
|
||||
Go directive bumped from 1.25 to 1.26. API committed as stable.
|
||||
NewConsole bootstraps the OTel SDK with three logz-backed exporters:
|
||||
- Trace: WithSyncer, one log line per closed span (immediate, no batch)
|
||||
- Metric: PeriodicReader (60s), flushed on shutdown
|
||||
- OTel log: BatchProcessor, for third-party libs using OTel log API
|
||||
|
||||
ConsoleConfig requires only ServiceName — no OTLP endpoint needed.
|
||||
Adds logz v1.0.1 as direct dependency; module tier bumped 1 → 2.
|
||||
|
||||
61
RELEASE.md
61
RELEASE.md
@@ -1,46 +1,63 @@
|
||||
# v1.0.0
|
||||
# v1.1.0
|
||||
|
||||
> `code.nochebuena.dev/go/telemetry`
|
||||
|
||||
## Overview
|
||||
|
||||
`telemetry` bootstraps the full OpenTelemetry SDK with OTLP gRPC exporters targeting
|
||||
a Grafana Alloy collector. A single call to `New` sets all three OTel global providers
|
||||
(traces → Tempo, metrics → Mimir, logs → Loki) and returns a shutdown function the
|
||||
caller defers in `main`.
|
||||
v1.1.0 adds `NewConsole` — a logz-backed alternative to `New` for local development.
|
||||
All OTel signals (traces, metrics, OTel log records) are emitted as structured logz log
|
||||
lines instead of being forwarded to a Grafana Alloy collector. No infrastructure required.
|
||||
|
||||
v1.0.0 adds named per-provider shutdown errors, bumps the Go directive to 1.26, and
|
||||
commits the API as stable. No micro-lib dependencies are added.
|
||||
## What Changed Since v1.0.0
|
||||
|
||||
## What Changed Since v0.9.0
|
||||
### New: `NewConsole`
|
||||
|
||||
### Named per-provider shutdown errors
|
||||
|
||||
The shutdown function now labels each provider failure individually:
|
||||
|
||||
```
|
||||
telemetry: trace provider shutdown: <cause>
|
||||
telemetry: metric provider shutdown: <cause>
|
||||
telemetry: log provider shutdown: <cause>
|
||||
```go
|
||||
func NewConsole(ctx context.Context, logger logz.Logger, cfg ConsoleConfig) (func(context.Context) error, error)
|
||||
```
|
||||
|
||||
Errors are still joined with `errors.Join`; individual causes remain accessible via
|
||||
`errors.As`. Previously, provider shutdown failures were joined without labels, making
|
||||
it impossible to identify which signal pipeline failed.
|
||||
Same shutdown pattern as `New` — drop-in swap for development environments.
|
||||
|
||||
### Go directive bumped to 1.26
|
||||
**ConsoleConfig** requires only service identity fields:
|
||||
|
||||
| Field | Env var | Required | Default |
|
||||
|-------|---------|----------|---------|
|
||||
| `ServiceName` | `OTEL_SERVICE_NAME` | yes | — |
|
||||
| `ServiceVersion` | `OTEL_SERVICE_VERSION` | no | `unknown` |
|
||||
| `Environment` | `OTEL_ENVIRONMENT` | no | `development` |
|
||||
|
||||
**Output format:**
|
||||
```
|
||||
INFO otel: span name=GET /api/v1/permisos trace_id=3f2a9c... duration_ms=18 status=Ok
|
||||
INFO otel: metric name=http.server.duration kind=histogram count=5 sum=87.3 unit=ms
|
||||
INFO otel: log body=cache miss severity=INFO
|
||||
```
|
||||
|
||||
**Signal details:**
|
||||
- **Traces** — synchronous (`WithSyncer`): one log line immediately on `span.End()`
|
||||
- **Metrics** — periodic reader (60s interval), flushed on shutdown
|
||||
- **OTel logs** — batch processor, for third-party libs using the OTel log API
|
||||
|
||||
**Warning:** do not wire the OTel slog bridge alongside `NewConsole`. It would create a
|
||||
feedback loop: logz → slog → OTel log API → logLogExporter → logz.
|
||||
|
||||
### Dependency update
|
||||
|
||||
- `logz v1.0.1` added as direct dependency; module tier bumped from 1 → 2
|
||||
|
||||
## Full API (stable)
|
||||
|
||||
- `Config` — `ServiceName`, `ServiceVersion`, `Environment`, `OTLPEndpoint`, `OTLPInsecure`
|
||||
- `ConsoleConfig` — `ServiceName`, `ServiceVersion`, `Environment`
|
||||
- `New(ctx, cfg) (func(context.Context) error, error)`
|
||||
- `NewConsole(ctx, logger, cfg) (func(context.Context) error, error)`
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
go get code.nochebuena.dev/go/telemetry@v1.0.0
|
||||
go get code.nochebuena.dev/go/telemetry@v1.1.0
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md#100---2026-05-12).
|
||||
See [CHANGELOG.md](CHANGELOG.md#110---2026-05-12).
|
||||
|
||||
183
console.go
Normal file
183
console.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
otellog "go.opentelemetry.io/otel/log"
|
||||
"go.opentelemetry.io/otel/log/global"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
sdklog "go.opentelemetry.io/otel/sdk/log"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
|
||||
|
||||
"code.nochebuena.dev/go/logz"
|
||||
)
|
||||
|
||||
// ConsoleConfig holds the minimum OTel configuration needed for console/dev mode.
|
||||
// Only service identity fields are required — no OTLP endpoint.
|
||||
type ConsoleConfig struct {
|
||||
ServiceName string `env:"OTEL_SERVICE_NAME,required"`
|
||||
ServiceVersion string `env:"OTEL_SERVICE_VERSION" envDefault:"unknown"`
|
||||
Environment string `env:"OTEL_ENVIRONMENT" envDefault:"development"`
|
||||
}
|
||||
|
||||
// NewConsole bootstraps the OTel SDK with logz-backed exporters for local development.
|
||||
// All signals (traces, metrics, OTel log records) are emitted as structured logz log
|
||||
// lines instead of being sent to a collector.
|
||||
//
|
||||
// Drop-in alternative to [New] for development environments.
|
||||
//
|
||||
// Warning: do not wire the OTel slog bridge alongside NewConsole — routing logz through
|
||||
// the OTel log API and back through logLogExporter creates a feedback loop.
|
||||
func NewConsole(ctx context.Context, logger logz.Logger, cfg ConsoleConfig) (func(context.Context) error, error) {
|
||||
res, err := resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceName(cfg.ServiceName),
|
||||
semconv.ServiceVersion(cfg.ServiceVersion),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
res = resource.Default()
|
||||
}
|
||||
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithSyncer(&logTraceExporter{logger: logger}),
|
||||
sdktrace.WithResource(res),
|
||||
)
|
||||
otel.SetTracerProvider(tp)
|
||||
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
))
|
||||
|
||||
mp := sdkmetric.NewMeterProvider(
|
||||
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(&logMetricExporter{logger: logger})),
|
||||
sdkmetric.WithResource(res),
|
||||
)
|
||||
otel.SetMeterProvider(mp)
|
||||
|
||||
lp := sdklog.NewLoggerProvider(
|
||||
sdklog.WithProcessor(sdklog.NewBatchProcessor(&logLogExporter{logger: logger})),
|
||||
sdklog.WithResource(res),
|
||||
)
|
||||
global.SetLoggerProvider(lp)
|
||||
|
||||
shutdown := func(ctx context.Context) error {
|
||||
var errs []error
|
||||
if err := tp.Shutdown(ctx); err != nil {
|
||||
errs = append(errs, &providerErr{"trace", err})
|
||||
}
|
||||
if err := mp.Shutdown(ctx); err != nil {
|
||||
errs = append(errs, &providerErr{"metric", err})
|
||||
}
|
||||
if err := lp.Shutdown(ctx); err != nil {
|
||||
errs = append(errs, &providerErr{"log", err})
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
return shutdown, nil
|
||||
}
|
||||
|
||||
// --- trace exporter ---
|
||||
|
||||
type logTraceExporter struct{ logger logz.Logger }
|
||||
|
||||
func (e *logTraceExporter) ExportSpans(_ context.Context, spans []sdktrace.ReadOnlySpan) error {
|
||||
for _, s := range spans {
|
||||
e.logger.Info("otel: span",
|
||||
"name", s.Name(),
|
||||
"trace_id", s.SpanContext().TraceID(),
|
||||
"span_id", s.SpanContext().SpanID(),
|
||||
"parent_span_id", s.Parent().SpanID(),
|
||||
"kind", s.SpanKind(),
|
||||
"duration_ms", s.EndTime().Sub(s.StartTime()).Milliseconds(),
|
||||
"status", s.Status().Code,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *logTraceExporter) Shutdown(_ context.Context) error { return nil }
|
||||
|
||||
// --- metric exporter ---
|
||||
|
||||
type logMetricExporter struct{ logger logz.Logger }
|
||||
|
||||
func (e *logMetricExporter) Temporality(_ sdkmetric.InstrumentKind) metricdata.Temporality {
|
||||
return metricdata.CumulativeTemporality
|
||||
}
|
||||
|
||||
func (e *logMetricExporter) Aggregation(k sdkmetric.InstrumentKind) sdkmetric.Aggregation {
|
||||
return sdkmetric.DefaultAggregationSelector(k)
|
||||
}
|
||||
|
||||
func (e *logMetricExporter) Export(_ context.Context, rm *metricdata.ResourceMetrics) error {
|
||||
for _, sm := range rm.ScopeMetrics {
|
||||
for _, m := range sm.Metrics {
|
||||
e.logMetric(m)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *logMetricExporter) logMetric(m metricdata.Metrics) {
|
||||
switch d := m.Data.(type) {
|
||||
case metricdata.Sum[int64]:
|
||||
for _, dp := range d.DataPoints {
|
||||
e.logger.Info("otel: metric", "name", m.Name, "kind", "sum", "value", dp.Value, "unit", m.Unit)
|
||||
}
|
||||
case metricdata.Sum[float64]:
|
||||
for _, dp := range d.DataPoints {
|
||||
e.logger.Info("otel: metric", "name", m.Name, "kind", "sum", "value", dp.Value, "unit", m.Unit)
|
||||
}
|
||||
case metricdata.Gauge[int64]:
|
||||
for _, dp := range d.DataPoints {
|
||||
e.logger.Info("otel: metric", "name", m.Name, "kind", "gauge", "value", dp.Value, "unit", m.Unit)
|
||||
}
|
||||
case metricdata.Gauge[float64]:
|
||||
for _, dp := range d.DataPoints {
|
||||
e.logger.Info("otel: metric", "name", m.Name, "kind", "gauge", "value", dp.Value, "unit", m.Unit)
|
||||
}
|
||||
case metricdata.Histogram[int64]:
|
||||
for _, dp := range d.DataPoints {
|
||||
e.logger.Info("otel: metric", "name", m.Name, "kind", "histogram", "count", dp.Count, "sum", dp.Sum, "unit", m.Unit)
|
||||
}
|
||||
case metricdata.Histogram[float64]:
|
||||
for _, dp := range d.DataPoints {
|
||||
e.logger.Info("otel: metric", "name", m.Name, "kind", "histogram", "count", dp.Count, "sum", dp.Sum, "unit", m.Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *logMetricExporter) ForceFlush(_ context.Context) error { return nil }
|
||||
func (e *logMetricExporter) Shutdown(_ context.Context) error { return nil }
|
||||
|
||||
// --- log exporter ---
|
||||
|
||||
type logLogExporter struct{ logger logz.Logger }
|
||||
|
||||
func (e *logLogExporter) Export(_ context.Context, records []sdklog.Record) error {
|
||||
for _, r := range records {
|
||||
body := r.Body().AsString()
|
||||
sev := r.Severity()
|
||||
switch {
|
||||
case sev >= otellog.SeverityError:
|
||||
e.logger.Error("otel: log", nil, "body", body, "severity", r.SeverityText())
|
||||
case sev >= otellog.SeverityWarn:
|
||||
e.logger.Warn("otel: log", "body", body, "severity", r.SeverityText())
|
||||
default:
|
||||
e.logger.Info("otel: log", "body", body, "severity", r.SeverityText())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *logLogExporter) ForceFlush(_ context.Context) error { return nil }
|
||||
func (e *logLogExporter) Shutdown(_ context.Context) error { return nil }
|
||||
181
console_test.go
Normal file
181
console_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
|
||||
"code.nochebuena.dev/go/logz"
|
||||
)
|
||||
|
||||
// stubLogger captures Info/Warn/Error calls for test assertions.
|
||||
type stubLogger struct {
|
||||
mu sync.Mutex
|
||||
logs []stubLog
|
||||
}
|
||||
|
||||
type stubLog struct {
|
||||
level string
|
||||
msg string
|
||||
args []any
|
||||
}
|
||||
|
||||
func (s *stubLogger) Info(msg string, args ...any) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.logs = append(s.logs, stubLog{"info", msg, args})
|
||||
}
|
||||
|
||||
func (s *stubLogger) Warn(msg string, args ...any) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.logs = append(s.logs, stubLog{"warn", msg, args})
|
||||
}
|
||||
|
||||
func (s *stubLogger) Error(msg string, err error, args ...any) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.logs = append(s.logs, stubLog{"error", msg, args})
|
||||
}
|
||||
|
||||
func (s *stubLogger) Debug(msg string, args ...any) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.logs = append(s.logs, stubLog{"debug", msg, args})
|
||||
}
|
||||
|
||||
func (s *stubLogger) With(args ...any) logz.Logger { return s }
|
||||
func (s *stubLogger) WithContext(ctx context.Context) logz.Logger { return s }
|
||||
|
||||
func (s *stubLogger) find(msg string) (stubLog, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, l := range s.logs {
|
||||
if l.msg == msg {
|
||||
return l, true
|
||||
}
|
||||
}
|
||||
return stubLog{}, false
|
||||
}
|
||||
|
||||
func consoleCfg() ConsoleConfig {
|
||||
return ConsoleConfig{
|
||||
ServiceName: "test-svc",
|
||||
ServiceVersion: "0.0.1",
|
||||
Environment: "test",
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConsole_ShutdownCallable(t *testing.T) {
|
||||
logger := &stubLogger{}
|
||||
ctx := context.Background()
|
||||
|
||||
shutdown, err := NewConsole(ctx, logger, consoleCfg())
|
||||
if err != nil {
|
||||
t.Fatalf("NewConsole: %v", err)
|
||||
}
|
||||
if shutdown == nil {
|
||||
t.Fatal("shutdown is nil")
|
||||
}
|
||||
shutCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := shutdown(shutCtx); err != nil {
|
||||
t.Errorf("shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConsole_SetsGlobalTracerProvider(t *testing.T) {
|
||||
logger := &stubLogger{}
|
||||
ctx := context.Background()
|
||||
|
||||
shutdown, err := NewConsole(ctx, logger, consoleCfg())
|
||||
if err != nil {
|
||||
t.Fatalf("NewConsole: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
shutCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
||||
defer cancel()
|
||||
_ = shutdown(shutCtx)
|
||||
})
|
||||
|
||||
tp := otel.GetTracerProvider()
|
||||
if _, ok := tp.(*sdktrace.TracerProvider); !ok {
|
||||
t.Errorf("expected *sdktrace.TracerProvider, got %T", tp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConsole_SetsGlobalMeterProvider(t *testing.T) {
|
||||
logger := &stubLogger{}
|
||||
ctx := context.Background()
|
||||
|
||||
shutdown, err := NewConsole(ctx, logger, consoleCfg())
|
||||
if err != nil {
|
||||
t.Fatalf("NewConsole: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
shutCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
||||
defer cancel()
|
||||
_ = shutdown(shutCtx)
|
||||
})
|
||||
|
||||
mp := otel.GetMeterProvider()
|
||||
if _, ok := mp.(*sdkmetric.MeterProvider); !ok {
|
||||
t.Errorf("expected *sdkmetric.MeterProvider, got %T", mp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConsole_ExportsSpan(t *testing.T) {
|
||||
logger := &stubLogger{}
|
||||
ctx := context.Background()
|
||||
|
||||
shutdown, err := NewConsole(ctx, logger, consoleCfg())
|
||||
if err != nil {
|
||||
t.Fatalf("NewConsole: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
shutCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
||||
defer cancel()
|
||||
_ = shutdown(shutCtx)
|
||||
}()
|
||||
|
||||
tracer := otel.Tracer("test")
|
||||
_, span := tracer.Start(ctx, "test-span")
|
||||
span.End() // WithSyncer exports synchronously on End
|
||||
|
||||
if _, ok := logger.find("otel: span"); !ok {
|
||||
t.Error("expected logger to receive 'otel: span' after span.End()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConsole_ExportsMetric(t *testing.T) {
|
||||
logger := &stubLogger{}
|
||||
ctx := context.Background()
|
||||
|
||||
shutdown, err := NewConsole(ctx, logger, consoleCfg())
|
||||
if err != nil {
|
||||
t.Fatalf("NewConsole: %v", err)
|
||||
}
|
||||
|
||||
meter := otel.Meter("test")
|
||||
counter, err := meter.Int64Counter("test.counter")
|
||||
if err != nil {
|
||||
t.Fatalf("Int64Counter: %v", err)
|
||||
}
|
||||
counter.Add(ctx, 1)
|
||||
|
||||
// Force export by shutting down — PeriodicReader flushes on shutdown.
|
||||
shutCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
if err := shutdown(shutCtx); err != nil {
|
||||
t.Errorf("shutdown: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := logger.find("otel: metric"); !ok {
|
||||
t.Error("expected logger to receive 'otel: metric' after shutdown flush")
|
||||
}
|
||||
}
|
||||
1
go.mod
1
go.mod
@@ -3,6 +3,7 @@ module code.nochebuena.dev/go/telemetry
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
code.nochebuena.dev/go/logz v1.0.1
|
||||
go.opentelemetry.io/otel v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -1,3 +1,5 @@
|
||||
code.nochebuena.dev/go/logz v1.0.1 h1:kK9aZo19L208CwCr2D/dbSOMaOv62cXsigMSsdFu+8Y=
|
||||
code.nochebuena.dev/go/logz v1.0.1/go.mod h1:YNpNm03fURm2v0ySh/477z9AJhtfRcd9rFOW6fFqgNM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
|
||||
Reference in New Issue
Block a user