diff --git a/CHANGELOG.md b/CHANGELOG.md index dca4106..0212360 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/CLAUDE.md b/CLAUDE.md index 2c90f01..07ffa84 100644 --- a/CLAUDE.md +++ b/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. diff --git a/COMMIT.md b/COMMIT.md index 1ec4407..f9282c0 100644 --- a/COMMIT.md +++ b/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. diff --git a/RELEASE.md b/RELEASE.md index 2a276d3..ee772d1 100644 --- a/RELEASE.md +++ b/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: -telemetry: metric provider shutdown: -telemetry: log provider shutdown: +```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). diff --git a/console.go b/console.go new file mode 100644 index 0000000..cb327f3 --- /dev/null +++ b/console.go @@ -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 } diff --git a/console_test.go b/console_test.go new file mode 100644 index 0000000..0838f1f --- /dev/null +++ b/console_test.go @@ -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") + } +} diff --git a/go.mod b/go.mod index 22f718b..f871c07 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index c34a10e..4087ae3 100644 --- a/go.sum +++ b/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=