diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..94f372d
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+github: [LerianStudio]
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..d966402
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,50 @@
+version: 2
+updates:
+ # Go modules
+ - package-ecosystem: "gomod"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ day: "monday"
+ time: "08:00"
+ timezone: "America/Sao_Paulo"
+ target-branch: "develop"
+ open-pull-requests-limit: 10
+ labels:
+ - "deps"
+ commit-message:
+ prefix: "chore"
+ include: "scope"
+ groups:
+ go-dependencies:
+ patterns:
+ - "*"
+
+ # Pinned third-party GitHub Actions
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ day: "monday"
+ time: "08:00"
+ timezone: "America/Sao_Paulo"
+ target-branch: "develop"
+ open-pull-requests-limit: 10
+ labels:
+ - "deps"
+ - "ci"
+ commit-message:
+ prefix: "chore"
+ include: "scope"
+ ignore:
+ # LerianStudio internal actions are pinned intentionally
+ - dependency-name: "LerianStudio/*"
+ groups:
+ actions-core:
+ patterns:
+ - "actions/*"
+ security-actions:
+ patterns:
+ - "github/codeql-action*"
+ - "aquasecurity/trivy-action"
+ - "securego/gosec"
diff --git a/.github/labeler.yml b/.github/labeler.yml
new file mode 100644
index 0000000..24fd534
--- /dev/null
+++ b/.github/labeler.yml
@@ -0,0 +1,99 @@
+# Pull Request Labeler configuration
+#
+# Labels are aligned with the pr_title_scopes in pr-validation.yml.
+
+# Assertion helpers
+assert:
+ - changed-files:
+ - any-glob-to-any-file: 'assert/**/*'
+
+# Shared constants
+constants:
+ - changed-files:
+ - any-glob-to-any-file: 'constants/**/*'
+
+# Structured logging primitives
+log:
+ - changed-files:
+ - any-glob-to-any-file: 'log/**/*'
+
+# Metrics instrumentation
+metrics:
+ - changed-files:
+ - any-glob-to-any-file: 'metrics/**/*'
+
+# HTTP/gRPC observability middleware
+middleware:
+ - changed-files:
+ - any-glob-to-any-file: 'middleware/**/*'
+
+# Log/data redaction utilities
+redaction:
+ - changed-files:
+ - any-glob-to-any-file: 'redaction/**/*'
+
+# Runtime introspection
+runtime:
+ - changed-files:
+ - any-glob-to-any-file: 'runtime/**/*'
+
+# Distributed tracing
+tracing:
+ - changed-files:
+ - any-glob-to-any-file: 'tracing/**/*'
+
+# Zap logger integration
+zap:
+ - changed-files:
+ - any-glob-to-any-file: 'zap/**/*'
+
+# Top-level public API (observability.go, system.go, context_helpers.go)
+core:
+ - changed-files:
+ - any-glob-to-any-file:
+ - 'observability.go'
+ - 'system.go'
+ - 'context_helpers.go'
+
+# Build and tooling scripts
+scripts:
+ - changed-files:
+ - any-glob-to-any-file:
+ - 'scripts/**/*'
+ - 'Makefile'
+
+# Application and tooling configuration
+config:
+ - changed-files:
+ - any-glob-to-any-file:
+ - '.golangci.yml'
+ - '.releaserc.yml'
+ - '.goreleaser.yml'
+ - '.ignorecoverunit'
+
+# Continuous integration pipelines and shared workflows
+ci:
+ - changed-files:
+ - any-glob-to-any-file: '.github/workflows/**/*'
+
+# Markdown docs and llms.txt context files
+docs:
+ - changed-files:
+ - any-glob-to-any-file:
+ - '**/*.md'
+ - 'docs/**/*'
+ - 'llms.txt'
+
+# Unit, integration and end-to-end tests
+tests:
+ - changed-files:
+ - any-glob-to-any-file:
+ - '**/*_test.go'
+ - 'tests/**/*'
+
+# Go module dependencies
+deps:
+ - changed-files:
+ - any-glob-to-any-file:
+ - 'go.mod'
+ - 'go.sum'
diff --git a/.github/labels.yml b/.github/labels.yml
new file mode 100644
index 0000000..6567dba
--- /dev/null
+++ b/.github/labels.yml
@@ -0,0 +1,148 @@
+# Label definitions for lib-observability
+# Synced via .github/workflows/routine.yml -> shared labels-sync.
+
+# ── Scopes (aligned with .github/labeler.yml) ─────────────────────────────────
+
+- name: assert
+ color: "1d76db"
+ description: Assertion helpers
+
+- name: constants
+ color: "0e8a16"
+ description: Shared constants
+
+- name: log
+ color: "fbca04"
+ description: Structured logging primitives
+
+- name: metrics
+ color: "c5def5"
+ description: Metrics instrumentation
+
+- name: middleware
+ color: "5319e7"
+ description: HTTP/gRPC observability middleware
+
+- name: redaction
+ color: "7c3aed"
+ description: Log/data redaction utilities
+
+- name: runtime
+ color: "0052cc"
+ description: Runtime introspection
+
+- name: tracing
+ color: "f9d0c4"
+ description: Distributed tracing
+
+- name: zap
+ color: "bfd4f2"
+ description: Zap logger integration
+
+- name: core
+ color: "0db7ed"
+ description: Top-level public API surface
+
+- name: scripts
+ color: "fef2c0"
+ description: Build and tooling scripts
+
+- name: config
+ color: "d4a017"
+ description: Application and tooling configuration
+
+- name: ci
+ color: "2088FF"
+ description: Continuous integration pipelines
+
+- name: docs
+ color: "0075ca"
+ description: Documentation, llms.txt and markdown content
+
+- name: tests
+ color: "00ADD8"
+ description: Unit, integration and end-to-end tests
+
+- name: deps
+ color: "ededed"
+ description: Go module dependencies (usually opened by Dependabot)
+
+# ── Change types ──────────────────────────────────────────────────────────────
+
+- name: bug
+ color: "d73a4a"
+ description: Something is not working as expected
+
+- name: enhancement
+ color: "a2eeef"
+ description: New feature or improvement request
+
+- name: dependencies
+ color: "ededed"
+ description: Dependency updates (Dependabot)
+
+- name: github-actions
+ color: "2088FF"
+ description: Updates to GitHub Actions dependencies
+
+- name: breaking-change
+ color: "b60205"
+ description: Callers must update their integration after this change
+
+- name: skip-changelog
+ color: "ffffff"
+ description: This change does not need a changelog entry
+
+# ── PR size ───────────────────────────────────────────────────────────────────
+
+- name: size/XS
+ color: "3CBF00"
+ description: "PR changes < 50 lines"
+
+- name: size/S
+ color: "5D9801"
+ description: "PR changes 50–199 lines"
+
+- name: size/M
+ color: "7F7203"
+ description: "PR changes 200–499 lines"
+
+- name: size/L
+ color: "A14C05"
+ description: "PR changes 500–999 lines"
+
+- name: size/XL
+ color: "C32607"
+ description: "PR changes ≥ 1000 lines — consider splitting"
+
+# ── Triage and stale management ───────────────────────────────────────────────
+
+- name: triage
+ color: "e4e669"
+ description: Needs initial assessment
+
+- name: stale
+ color: "795548"
+ description: No recent activity — will be closed if not updated
+
+- name: no-stale
+ color: "fef2c0"
+ description: Exempt from stale auto-close
+
+- name: work-in-progress
+ color: "ffa500"
+ description: Active work — exempt from stale auto-close
+
+- name: pinned
+ color: "d4a017"
+ description: Pinned discussion/tracker — exempt from stale auto-close
+
+# ── Automation ────────────────────────────────────────────────────────────────
+
+- name: automated
+ color: "ededed"
+ description: Opened by automation (release backmerges, etc.)
+
+- name: backmerge
+ color: "5319e7"
+ description: Backmerge PR (main → develop after release)
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..28f489f
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,59 @@
+
+
+  |
+ Lib Observability |
+
+
+
+---
+
+## Description
+
+
+
+## Type of Change
+
+- [ ] `feat`: New feature or capability
+- [ ] `fix`: Bug fix
+- [ ] `perf`: Performance improvement
+- [ ] `refactor`: Internal restructuring with no behavior change
+- [ ] `docs`: Documentation only (README, docs/, inline comments)
+- [ ] `style`: Formatting, whitespace, naming (no logic change)
+- [ ] `test`: Adding or updating tests
+- [ ] `ci`: CI pipeline or workflow changes
+- [ ] `build`: Build system, Go module dependencies
+- [ ] `chore`: Maintenance, config, tooling
+- [ ] `revert`: Reverts a previous commit
+- [ ] `BREAKING CHANGE`: Consumers must update their integration
+
+## Breaking Changes
+
+
+
+None.
+
+## Testing
+
+- [ ] `make test` passes
+- [ ] `make test-int` passes if integration paths are exercised
+- [ ] `make lint` passes
+- [ ] Coverage threshold respected (see Go Combined Analysis)
+
+**Test evidence / Actions run:**
+
+## Architectural Checklist
+
+- [ ] No `panic()` in production paths — uses `assert` helpers or wrapped errors
+- [ ] Timestamps use `time.Now().UTC()`
+- [ ] Errors wrapped with `%w`
+- [ ] Public API additions documented (godoc + README if user-facing)
+- [ ] No leaking of context or goroutines (instrumentation must be safe under load)
+- [ ] Backwards-compatible by default; breaking changes flagged above
+- [ ] No `lib-commons` import (circular — see CLAUDE.md)
+
+## Related Issues
+
+Closes #
diff --git a/.github/workflows/go-combined-analysis.yml b/.github/workflows/go-combined-analysis.yml
new file mode 100644
index 0000000..044c479
--- /dev/null
+++ b/.github/workflows/go-combined-analysis.yml
@@ -0,0 +1,38 @@
+name: Go Combined Analysis
+
+on:
+ pull_request:
+ branches: [develop, release-candidate, main]
+ types: [opened, synchronize, reopened]
+ paths-ignore:
+ - '**.md'
+ - 'docs/**'
+ - '.github/**'
+ - 'LICENSE'
+ - '.gitignore'
+
+permissions:
+ id-token: write
+ contents: read
+ pull-requests: write
+ security-events: write
+
+jobs:
+ go-analysis:
+ name: Go Analysis
+ uses: LerianStudio/github-actions-shared-workflows/.github/workflows/go-pr-analysis.yml@v1.30.0
+ with:
+ runner_type: "blacksmith-4vcpu-ubuntu-2404"
+ app_name_prefix: "lib-observability"
+ go_version: "1.25.10"
+ golangci_lint_version: "v2.11.2"
+ golangci_lint_args: "--timeout=5m"
+ coverage_threshold: 80
+ fail_on_coverage_threshold: true
+ enable_lint: true
+ enable_security: true
+ enable_tests: true
+ enable_coverage: true
+ enable_build: true
+ go_private_modules: "github.com/LerianStudio/*"
+ secrets: inherit
diff --git a/.github/workflows/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml
new file mode 100644
index 0000000..99a08ef
--- /dev/null
+++ b/.github/workflows/pr-security-scan.yml
@@ -0,0 +1,24 @@
+name: PR Security Scan
+
+on:
+ pull_request:
+ branches: [develop, release-candidate, main]
+ paths-ignore:
+ - '**.md'
+ - 'docs/**'
+ - 'LICENSE'
+ - '.gitignore'
+
+permissions:
+ actions: read
+ id-token: write
+ contents: read
+ pull-requests: write
+ security-events: write
+
+jobs:
+ security:
+ uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-security-scan.yml@v1.30.0
+ with:
+ enable_docker_scan: false
+ secrets: inherit
diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml
new file mode 100644
index 0000000..9f15d2e
--- /dev/null
+++ b/.github/workflows/pr-validation.yml
@@ -0,0 +1,37 @@
+name: PR Validation
+
+on:
+ pull_request:
+ branches: [develop, release-candidate, main]
+ types: [opened, edited, synchronize, reopened, ready_for_review]
+
+permissions:
+ contents: read
+ pull-requests: write
+ issues: write
+
+jobs:
+ validate:
+ uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.30.0
+ with:
+ enforce_source_branches: true
+ allowed_source_branches: 'develop|release-candidate|hotfix/*'
+ target_branches_for_source_check: 'main'
+ pr_title_scopes: |
+ assert
+ ci
+ config
+ constants
+ core
+ deps
+ docs
+ log
+ metrics
+ middleware
+ redaction
+ runtime
+ scripts
+ tests
+ tracing
+ zap
+ secrets: inherit
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..8a04afe
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,19 @@
+name: Release
+
+on:
+ push:
+ branches: [main, release-candidate, develop]
+
+permissions:
+ id-token: write
+ contents: write
+ issues: write
+ pull-requests: write
+ packages: read
+
+jobs:
+ release:
+ uses: LerianStudio/github-actions-shared-workflows/.github/workflows/release.yml@v1.30.0
+ with:
+ enable_changelog: ${{ github.ref == 'refs/heads/main' }}
+ secrets: inherit
diff --git a/.github/workflows/routine.yml b/.github/workflows/routine.yml
new file mode 100644
index 0000000..5cd27ff
--- /dev/null
+++ b/.github/workflows/routine.yml
@@ -0,0 +1,43 @@
+name: Repository Routines
+
+on:
+ schedule:
+ - cron: "0 3 * * 1"
+ pull_request:
+ types: [closed]
+ push:
+ branches: [main]
+ paths:
+ - .github/labels.yml
+ workflow_dispatch:
+ inputs:
+ routine:
+ description: Routine to run
+ type: choice
+ options:
+ - all
+ - branch-cleanup-stale
+ - stale-pr
+ - stale-issue
+ - labels-sync
+ - workflow-runs-cleanup
+ default: all
+ dry_run:
+ description: Preview changes without applying them
+ type: boolean
+ default: true
+
+permissions:
+ contents: write
+ pull-requests: write
+ issues: write
+ actions: write
+
+jobs:
+ routine:
+ uses: LerianStudio/github-actions-shared-workflows/.github/workflows/routine.yml@v1.30.0
+ with:
+ routine: ${{ inputs.routine || 'all' }}
+ dry_run: ${{ inputs.dry_run || false }}
+ merged_branch: ${{ github.head_ref }}
+ secrets: inherit
diff --git a/.gitignore b/.gitignore
index aaadf73..946933c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,32 +1,18 @@
-# If you prefer the allow list template instead of the deny list, see community template:
-# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
-#
-# Binaries for programs and plugins
-*.exe
-*.exe~
-*.dll
-*.so
-*.dylib
+.idea/
+.DS_Store
+.claude/
+.mcp.json
-# Test binary, built with `go test -c`
-*.test
+# Test reports and coverage
+reports/
+coverage.out
+coverage.html
+*_coverage.out
+*_coverage.html
-# Code coverage profiles and other test artifacts
-*.out
-coverage.*
-*.coverprofile
-profile.cov
+# Security scan reports
+gosec-report.sarif
-# Dependency directories (remove the comment below to include it)
-# vendor/
-
-# Go workspace file
-go.work
-go.work.sum
-
-# env file
-.env
-
-# Editor/IDE
-# .idea/
-# .vscode/
+docs/codereview/
+.codegraph/
+vendor/
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..fe97dce
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,171 @@
+version: "2"
+run:
+ tests: false
+linters:
+ enable:
+ # --- Existing linters ---
+ - bodyclose
+ - depguard
+ - dogsled
+ - dupword
+ - errchkjson
+ - gocognit
+ - gocyclo
+ - loggercheck
+ - misspell
+ - nakedret
+ - nilerr
+ - nolintlint
+ - prealloc
+ - predeclared
+ - reassign
+ - revive
+ - staticcheck
+ - unconvert
+ - unparam
+ - usestdlibvars
+ - wastedassign
+ - wsl_v5
+
+ # --- Tier 1: Safety & Correctness ---
+ - errorlint # type assertions on errors, missing %w
+ - exhaustive # non-exhaustive enum switches
+ - fatcontext # context growing in loop
+ - forcetypeassert # unchecked type assertions (sync.Map etc.)
+ - gosec # security issues (math/rand for jitter etc.)
+ - nilnil # return nil, nil ambiguity
+ - noctx # net.Listen/exec.Command without context
+
+ # --- Tier 2: Code Quality & Modernization ---
+ - goconst # repeated string literals
+ - gocritic # if-else→switch, deprecated comments
+ - inamedparam # unnamed interface params
+ - intrange # integer range loops
+ - mirror # allocation savings (bytes.Equal etc.)
+ - modernize # Go modernization suggestions
+ - perfsprint # fmt.Errorf→errors.New where applicable
+
+ # --- Tier 3: Zero-Issue Guards ---
+ - asasalint # variadic any argument passing
+ - copyloopvar # loop variable capture prevention
+ - durationcheck # time.Duration math bugs
+ - exptostd # x/exp to stdlib migration
+ - gocheckcompilerdirectives # malformed //go: comments
+ - makezero # make with non-zero length passed to append
+ - musttag # struct tag validation for marshaling
+ - nilnesserr # subtle nil error patterns
+ - recvcheck # receiver consistency
+ - rowserrcheck # SQL rows.Err() checks
+ - spancheck # OTEL span lifecycle
+ - sqlclosecheck # SQL resource close
+ - testifylint # testify assertion patterns
+
+ settings:
+ # --- New settings ---
+ exhaustive:
+ default-signifies-exhaustive: true
+
+ goconst:
+ min-len: 3
+ min-occurrences: 3
+ ignore-tests: true
+
+ # --- Existing settings (unchanged) ---
+ depguard:
+ rules:
+ main:
+ deny:
+ - pkg: io/ioutil
+ desc: The io/ioutil package has been deprecated, see https://go.dev/doc/go1.16#ioutil
+ gocyclo:
+ min-complexity: 16
+ govet:
+ enable:
+ - shadow
+ settings:
+ shadow:
+ strict: true
+ revive:
+ rules:
+ - name: import-shadowing
+ severity: warning
+ disabled: false
+ - name: empty-block
+ severity: warning
+ disabled: false
+ - name: empty-lines
+ severity: warning
+ disabled: false
+ - name: use-any
+ severity: warning
+ disabled: false
+ wsl_v5:
+ allow-first-in-block: true
+ allow-whole-block: false
+ branch-max-lines: 2
+
+ exclusions:
+ generated: lax
+ rules:
+ - linters:
+ - errcheck
+ text: Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*print(f|ln)?|os\.(Un)?Setenv). is not checked
+ - linters:
+ - revive
+ text: func name will be used as test\.Test.* by other packages, and that stutters; consider calling this
+ - linters:
+ - gosec
+ text: Use of unsafe calls should be audited
+ - linters:
+ - gosec
+ text: Subprocess launch(ed with variable|ing should be audited)
+ - linters:
+ - gosec
+ text: G307
+ - linters:
+ - gosec
+ text: (Expect directory permissions to be 0750 or less|Expect file permissions to be 0600 or less)
+ - linters:
+ - gosec
+ text: Potential file inclusion via variable
+ - linters:
+ - gosec
+ text: G113
+ - linters:
+ - gosec
+ text: G104
+ - linters:
+ - gosec
+ text: 'G204: Subprocess launched with a potential tainted input or cmd arguments'
+ - linters:
+ - gosec
+ text: 'G306: Expect WriteFile permissions to be 0600 or less'
+ - linters:
+ - revive
+ text: 'package-comments: should have a package comment'
+ - linters:
+ - errcheck
+ - gosec
+ path: _test\.go
+ - linters:
+ - staticcheck
+ text: 'ST1000: at least one file in a package should have a package comment'
+ - linters:
+ - govet
+ text: '^shadow: declaration of "(err|ok)" shadows declaration'
+ - path: (.+)\.go$
+ text: parameter .* always receives
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
+issues:
+ max-issues-per-linter: 0
+ max-same-issues: 0
+formatters:
+ exclusions:
+ generated: lax
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
diff --git a/.goreleaser.yml b/.goreleaser.yml
new file mode 100644
index 0000000..0af751e
--- /dev/null
+++ b/.goreleaser.yml
@@ -0,0 +1,41 @@
+version: 2
+
+# lib-observability is a Go library (no binary to build).
+# GoReleaser is used only for changelog generation and GitHub release creation.
+
+builds:
+ - skip: true
+
+changelog:
+ sort: asc
+ filters:
+ exclude:
+ - "merge conflict"
+ - Merge pull request
+ - Merge remote-tracking branch
+ - Merge branch
+ - go mod tidy
+ groups:
+ - title: "Breaking Changes"
+ regexp: "^.*breaking[(\\w)]*:+.*$"
+ order: 0
+ - title: "New Features"
+ regexp: "^.*feat[(\\w)]*:+.*$"
+ order: 10
+ - title: "Improvements"
+ regexp: "^.*chore[(\\w)]*:+.*$"
+ order: 20
+ - title: "Bug Fixes"
+ regexp: "^.*fix[(\\w)]*:+.*$"
+ order: 30
+ - title: "Security Updates"
+ regexp: '^.*?sec(\([[:word:]]+\))??!?:.+$'
+ order: 40
+ - title: "Documentation Updates"
+ regexp: ^.*?docs(\([[:word:]]+\))??!?:.+$
+ order: 50
+ - title: "Tests"
+ regexp: ^.*?(test(s)?(\([[:word:]]+\))?)!?:.+$
+ order: 60
+ - title: Other Work
+ order: 999
diff --git a/.ignorecoverunit b/.ignorecoverunit
new file mode 100644
index 0000000..36f7409
--- /dev/null
+++ b/.ignorecoverunit
@@ -0,0 +1,6 @@
+# Coverage exclusion patterns for unit tests
+# Lines starting with # are comments
+# Use * as wildcard for path matching
+
+# Auto-generated mocks
+*_mock.go
diff --git a/.releaserc.yml b/.releaserc.yml
new file mode 100644
index 0000000..eadae6b
--- /dev/null
+++ b/.releaserc.yml
@@ -0,0 +1,46 @@
+plugins:
+ - ["@semantic-release/commit-analyzer", {
+ preset: "conventionalcommits",
+ parserOpts: {
+ noteKeywords: [
+ "BREAKING CHANGE",
+ "BREAKING CHANGES"
+ ]
+ },
+ releaseRules: [
+ {type: "feat", release: "minor"},
+ {type: "perf", release: "minor"},
+ {type: "build", release: "minor"},
+ {type: "chore", release: "patch"},
+ {type: "ci", release: "patch"},
+ {type: "test", release: "patch"},
+ {type: "fix", release: "patch"},
+ {type: "refactor", release: "minor"},
+ {type: "docs", release: "patch"},
+ {breaking: true, release: "major"}
+ ]
+ }]
+
+ # Plugin to version the CHANGELOG.md
+ - ["@semantic-release/git", {
+ assets: ["CHANGELOG.md"],
+ message: "chore(release): ${nextRelease.version}\n\nupdated changelog."
+ }]
+
+ # Plugin to publish the CHANGELOG.md as an asset in the GitHub release
+ - ["@semantic-release/github", {
+ assets: [
+ {path: "CHANGELOG.md", label: "Changelog"}
+ ],
+ successComment: false,
+ failCommentCondition: false,
+ labels: []
+ }]
+ - "@semantic-release/exec"
+
+branches:
+ - name: main
+ - name: release-candidate
+ prerelease: rc
+ - name: develop
+ prerelease: beta
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..12ec710
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,58 @@
+# lib-observability — Project Conventions for Claude
+
+## What this repo is
+
+`github.com/LerianStudio/lib-observability` — standalone Go library for observability/telemetry,
+extracted from `lib-commons` (`../lib-commons`). Go 1.25.10.
+
+## Architecture decisions (non-negotiable)
+
+| Decision | Choice | Why |
+|---|---|---|
+| Package layout | Flat at module root — no `pkg/` or `observability/` parent | Avoids `lib-X/X/` import stutter; idiomatic Go |
+| `tracing/` package name | `package tracing` (not `package opentelemetry`) | Dir must match pkg name |
+| Context carriers | Root `package observability` (`observability.go`) | Shared by all packages; not a tracing concern |
+| Sensitive fields | `redaction/` package | Named by purpose; `IsSensitiveField(name string, extra ...string) bool` variadic |
+| HTTP middleware | `middleware/` package | Separate from core tracing |
+| Streaming telemetry | Stays in `lib-commons` | Streaming has domain/transport coupling outside this library |
+| lib-commons dependency | Avoid imports except compatibility-only streaming references | lib-commons will depend on lib-observability for shared observability |
+
+## Source of truth for migration
+
+When porting from lib-commons, source is always `../lib-commons/commons/`.
+Import remapping: `github.com/LerianStudio/lib-commons/v5/commons/X` → `github.com/LerianStudio/lib-observability/X`
+
+## Commit convention
+
+```bash
+git commit -S -m "type(scope): message" --trailer "X-Lerian-Ref: 0x1"
+```
+
+- Always GPG-sign (`-S`)
+- Always include trailer
+- Conventional commits (feat, fix, chore, docs, refactor, test)
+- Use `/ring:commit` skill for smart atomic grouping
+
+## How to call Gandalf (Lerian AI — Tailscale only)
+
+Use for: Lerian environment context, team architecture decisions, Midaz service conventions.
+
+```bash
+RESP=$(curl -s -X POST http://gandalf.heron-justitia.ts.net:18792/task \
+ -H "Content-Type: application/json" \
+ -d '{"action":"ask","message":"YOUR QUESTION","context":"lib-observability"}')
+TASK_ID=$(echo $RESP | jq -r .task_id)
+for i in $(seq 1 60); do
+ RESULT=$(curl -s http://gandalf.heron-justitia.ts.net:18792/task/$TASK_ID)
+ STATUS=$(echo $RESULT | jq -r .status)
+ [ "$STATUS" != "processing" ] && echo $RESULT | jq -r .response && break
+ sleep 5
+done
+```
+
+## CI/CD notes
+
+- Lint: `golangci-lint run ./...` (config in `.golangci.yml`)
+- Tests: `go test -tags=unit ./...` (integration tests require Docker)
+- Mocks: generated with `mockgen`, committed alongside source
+- Release: semantic-release on push to `develop` (beta) / `main` (stable)
diff --git a/LICENSE b/LICENSE
index 261eeb9..d317b57 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,201 +1,93 @@
- Apache License
- Version 2.0, January 2004
- http://www.apache.org/licenses/
-
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
- 1. Definitions.
-
- "License" shall mean the terms and conditions for use, reproduction,
- and distribution as defined by Sections 1 through 9 of this document.
-
- "Licensor" shall mean the copyright owner or entity authorized by
- the copyright owner that is granting the License.
-
- "Legal Entity" shall mean the union of the acting entity and all
- other entities that control, are controlled by, or are under common
- control with that entity. For the purposes of this definition,
- "control" means (i) the power, direct or indirect, to cause the
- direction or management of such entity, whether by contract or
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
- outstanding shares, or (iii) beneficial ownership of such entity.
-
- "You" (or "Your") shall mean an individual or Legal Entity
- exercising permissions granted by this License.
-
- "Source" form shall mean the preferred form for making modifications,
- including but not limited to software source code, documentation
- source, and configuration files.
-
- "Object" form shall mean any form resulting from mechanical
- transformation or translation of a Source form, including but
- not limited to compiled object code, generated documentation,
- and conversions to other media types.
-
- "Work" shall mean the work of authorship, whether in Source or
- Object form, made available under the License, as indicated by a
- copyright notice that is included in or attached to the work
- (an example is provided in the Appendix below).
-
- "Derivative Works" shall mean any work, whether in Source or Object
- form, that is based on (or derived from) the Work and for which the
- editorial revisions, annotations, elaborations, or other modifications
- represent, as a whole, an original work of authorship. For the purposes
- of this License, Derivative Works shall not include works that remain
- separable from, or merely link (or bind by name) to the interfaces of,
- the Work and Derivative Works thereof.
-
- "Contribution" shall mean any work of authorship, including
- the original version of the Work and any modifications or additions
- to that Work or Derivative Works thereof, that is intentionally
- submitted to Licensor for inclusion in the Work by the copyright owner
- or by an individual or Legal Entity authorized to submit on behalf of
- the copyright owner. For the purposes of this definition, "submitted"
- means any form of electronic, verbal, or written communication sent
- to the Licensor or its representatives, including but not limited to
- communication on electronic mailing lists, source code control systems,
- and issue tracking systems that are managed by, or on behalf of, the
- Licensor for the purpose of discussing and improving the Work, but
- excluding communication that is conspicuously marked or otherwise
- designated in writing by the copyright owner as "Not a Contribution."
-
- "Contributor" shall mean Licensor and any individual or Legal Entity
- on behalf of whom a Contribution has been received by Licensor and
- subsequently incorporated within the Work.
-
- 2. Grant of Copyright License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- copyright license to reproduce, prepare Derivative Works of,
- publicly display, publicly perform, sublicense, and distribute the
- Work and such Derivative Works in Source or Object form.
-
- 3. Grant of Patent License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- (except as stated in this section) patent license to make, have made,
- use, offer to sell, sell, import, and otherwise transfer the Work,
- where such license applies only to those patent claims licensable
- by such Contributor that are necessarily infringed by their
- Contribution(s) alone or by combination of their Contribution(s)
- with the Work to which such Contribution(s) was submitted. If You
- institute patent litigation against any entity (including a
- cross-claim or counterclaim in a lawsuit) alleging that the Work
- or a Contribution incorporated within the Work constitutes direct
- or contributory patent infringement, then any patent licenses
- granted to You under this License for that Work shall terminate
- as of the date such litigation is filed.
-
- 4. Redistribution. You may reproduce and distribute copies of the
- Work or Derivative Works thereof in any medium, with or without
- modifications, and in Source or Object form, provided that You
- meet the following conditions:
-
- (a) You must give any other recipients of the Work or
- Derivative Works a copy of this License; and
-
- (b) You must cause any modified files to carry prominent notices
- stating that You changed the files; and
-
- (c) You must retain, in the Source form of any Derivative Works
- that You distribute, all copyright, patent, trademark, and
- attribution notices from the Source form of the Work,
- excluding those notices that do not pertain to any part of
- the Derivative Works; and
-
- (d) If the Work includes a "NOTICE" text file as part of its
- distribution, then any Derivative Works that You distribute must
- include a readable copy of the attribution notices contained
- within such NOTICE file, excluding those notices that do not
- pertain to any part of the Derivative Works, in at least one
- of the following places: within a NOTICE text file distributed
- as part of the Derivative Works; within the Source form or
- documentation, if provided along with the Derivative Works; or,
- within a display generated by the Derivative Works, if and
- wherever such third-party notices normally appear. The contents
- of the NOTICE file are for informational purposes only and
- do not modify the License. You may add Your own attribution
- notices within Derivative Works that You distribute, alongside
- or as an addendum to the NOTICE text from the Work, provided
- that such additional attribution notices cannot be construed
- as modifying the License.
-
- You may add Your own copyright statement to Your modifications and
- may provide additional or different license terms and conditions
- for use, reproduction, or distribution of Your modifications, or
- for any such Derivative Works as a whole, provided Your use,
- reproduction, and distribution of the Work otherwise complies with
- the conditions stated in this License.
-
- 5. Submission of Contributions. Unless You explicitly state otherwise,
- any Contribution intentionally submitted for inclusion in the Work
- by You to the Licensor shall be under the terms and conditions of
- this License, without any additional terms or conditions.
- Notwithstanding the above, nothing herein shall supersede or modify
- the terms of any separate license agreement you may have executed
- with Licensor regarding such Contributions.
-
- 6. Trademarks. This License does not grant permission to use the trade
- names, trademarks, service marks, or product names of the Licensor,
- except as required for reasonable and customary use in describing the
- origin of the Work and reproducing the content of the NOTICE file.
-
- 7. Disclaimer of Warranty. Unless required by applicable law or
- agreed to in writing, Licensor provides the Work (and each
- Contributor provides its Contributions) on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
- implied, including, without limitation, any warranties or conditions
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
- PARTICULAR PURPOSE. You are solely responsible for determining the
- appropriateness of using or redistributing the Work and assume any
- risks associated with Your exercise of permissions under this License.
-
- 8. Limitation of Liability. In no event and under no legal theory,
- whether in tort (including negligence), contract, or otherwise,
- unless required by applicable law (such as deliberate and grossly
- negligent acts) or agreed to in writing, shall any Contributor be
- liable to You for damages, including any direct, indirect, special,
- incidental, or consequential damages of any character arising as a
- result of this License or out of the use or inability to use the
- Work (including but not limited to damages for loss of goodwill,
- work stoppage, computer failure or malfunction, or any and all
- other commercial damages or losses), even if such Contributor
- has been advised of the possibility of such damages.
-
- 9. Accepting Warranty or Additional Liability. While redistributing
- the Work or Derivative Works thereof, You may choose to offer,
- and charge a fee for, acceptance of support, warranty, indemnity,
- or other liability obligations and/or rights consistent with this
- License. However, in accepting such obligations, You may act only
- on Your own behalf and on Your sole responsibility, not on behalf
- of any other Contributor, and only if You agree to indemnify,
- defend, and hold each Contributor harmless for any liability
- incurred by, or claims asserted against, such Contributor by reason
- of your accepting any such warranty or additional liability.
-
- END OF TERMS AND CONDITIONS
-
- APPENDIX: How to apply the Apache License to your work.
-
- To apply the Apache License to your work, attach the following
- boilerplate notice, with the fields enclosed by brackets "[]"
- replaced with your own identifying information. (Don't include
- the brackets!) The text should be enclosed in the appropriate
- comment syntax for the file format. We also recommend that a
- file or class name and description of purpose be included on the
- same "printed page" as the copyright notice for easier
- identification within third-party archives.
-
- Copyright [yyyy] [name of copyright owner]
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
+Elastic License 2.0
+
+URL: https://www.elastic.co/licensing/elastic-license
+
+## Acceptance
+
+By using the software, you agree to all of the terms and conditions below.
+
+## Copyright License
+
+The licensor grants you a non-exclusive, royalty-free, worldwide,
+non-sublicensable, non-transferable license to use, copy, distribute, make
+available, and prepare derivative works of the software, in each case subject to
+the limitations and conditions below.
+
+## Limitations
+
+You may not provide the software to third parties as a hosted or managed
+service, where the service provides users with access to any substantial set of
+the features or functionality of the software.
+
+You may not move, change, disable, or circumvent the license key functionality
+in the software, and you may not remove or obscure any functionality in the
+software that is protected by the license key.
+
+You may not alter, remove, or obscure any licensing, copyright, or other notices
+of the licensor in the software. Any use of the licensor's trademarks is subject
+to applicable law.
+
+## Patents
+
+The licensor grants you a license, under any patent claims the licensor can
+license, or becomes able to license, to make, have made, use, sell, offer for
+sale, import and have imported the software, in each case subject to the
+limitations and conditions in this license. This license does not cover any
+patent claims that you cause to be infringed by modifications or additions to
+the software. If you or your company make any written claim that the software
+infringes or contributes to infringement of any patent, your patent license for
+the software granted under these terms ends immediately. If your company makes
+such a claim, your patent license ends immediately for work on behalf of your
+company.
+
+## Notices
+
+You must ensure that anyone who gets a copy of any part of the software from you
+also gets a copy of these terms.
+
+If you modify the software, you must include in any modified copies of the
+software prominent notices stating that you have modified the software.
+
+## No Other Rights
+
+These terms do not imply any licenses other than those expressly granted in
+these terms.
+
+## Termination
+
+If you use the software in violation of these terms, such use is not licensed,
+and your licenses will automatically terminate. If the licensor provides you
+with a notice of your violation, and you cease all violation of this license no
+later than 30 days after you receive that notice, your licenses will be
+reinstated retroactively. However, if you violate these terms after such
+reinstatement, any additional violation of these terms will cause your licenses
+to terminate automatically and permanently.
+
+## No Liability
+
+*As far as the law allows, the software comes as is, without any warranty or
+condition, and the licensor will not be liable to you for any damages arising
+out of these terms or the use or nature of the software, under any kind of
+legal claim.*
+
+## Definitions
+
+The **licensor** is the entity offering these terms, and the **software** is the
+software the licensor makes available under these terms, including any portion
+of it.
+
+**you** refers to the individual or entity agreeing to these terms.
+
+**your company** is any legal entity, sole proprietorship, or other kind of
+organization that you work for, plus all organizations that have control over,
+are under the control of, or are under common control with that
+organization. **control** means ownership of substantially all the assets of an
+entity, or the power to direct its management and policies by vote, contract, or
+otherwise. Control can be direct or indirect.
+
+**your licenses** are all the licenses granted to you for the software under
+these terms.
+
+**use** means anything you do with the software requiring one of your licenses.
+
+**trademark** means trademarks, service marks, and similar rights.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..c73b81e
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,616 @@
+# Default target when running bare `make`
+.DEFAULT_GOAL := help
+
+# Define the root directory of the project (resolves correctly even with make -f)
+LIB_OBSERVABILITY := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+
+# Include shared color definitions and utility functions
+include $(LIB_OBSERVABILITY)/scripts/makefile_colors.mk
+include $(LIB_OBSERVABILITY)/scripts/makefile_utils.mk
+
+# Define common utility functions
+define print_title
+ @echo ""
+ @echo "------------------------------------------"
+ @echo " 📝 $(1) "
+ @echo "------------------------------------------"
+endef
+
+# ------------------------------------------------------
+# Test configuration for lib-observability
+# ------------------------------------------------------
+
+# Integration test filter
+RUN ?=
+PKG ?=
+
+# Computed run pattern: uses RUN if set, otherwise defaults to '^TestIntegration'
+ifeq ($(RUN),)
+ RUN_PATTERN := ^TestIntegration
+else
+ RUN_PATTERN := $(RUN)
+endif
+
+# Low-resource mode for limited machines (sets -p=1 -parallel=1, disables -race)
+LOW_RESOURCE ?= 0
+
+# Computed flags for low-resource mode
+ifeq ($(LOW_RESOURCE),1)
+ LOW_RES_P_FLAG := -p 1
+ LOW_RES_PARALLEL_FLAG := -parallel 1
+ LOW_RES_RACE_FLAG :=
+else
+ LOW_RES_P_FLAG :=
+ LOW_RES_PARALLEL_FLAG :=
+ LOW_RES_RACE_FLAG := -race
+endif
+
+# macOS ld64 workaround
+UNAME_S := $(shell uname -s)
+ifeq ($(UNAME_S),Darwin)
+ ifneq ($(DISABLE_OSX_LINKER_WORKAROUND),1)
+ GO_TEST_LDFLAGS := -ldflags="-linkmode=external -extldflags=-ld_classic"
+ else
+ GO_TEST_LDFLAGS :=
+ endif
+else
+ GO_TEST_LDFLAGS :=
+endif
+
+# ------------------------------------------------------
+# Test tooling configuration
+# ------------------------------------------------------
+
+GOTESTSUM_VERSION ?= v1.12.0
+GOSEC_VERSION ?= v2.22.4
+GOLANGCI_LINT_VERSION ?= v2.1.6
+
+TEST_REPORTS_DIR ?= ./reports
+GOTESTSUM = $(shell command -v gotestsum 2>/dev/null)
+GOSEC = $(shell command -v gosec 2>/dev/null || \
+ GOBIN="$$(go env GOBIN)"; \
+ if [ -n "$$GOBIN" ]; then \
+ printf "%s/gosec" "$$GOBIN"; \
+ else \
+ printf "%s/bin/gosec" "$$(go env GOPATH)"; \
+ fi)
+RETRY_ON_FAIL ?= 0
+
+.PHONY: tools tools-gotestsum
+tools: tools-gotestsum ## Install helpful dev/test tools
+
+tools-gotestsum:
+ @if [ -z "$(GOTESTSUM)" ]; then \
+ echo "Installing gotestsum..."; \
+ GO111MODULE=on go install gotest.tools/gotestsum@$(GOTESTSUM_VERSION); \
+ else \
+ echo "gotestsum already installed: $(GOTESTSUM)"; \
+ fi
+
+#-------------------------------------------------------
+# Help Command
+#-------------------------------------------------------
+
+.PHONY: help
+help:
+ @echo ""
+ @echo ""
+ @echo "Lib-Observability Project Management Commands"
+ @echo ""
+ @echo ""
+ @echo "Core Commands:"
+ @echo " make help - Display this help message"
+ @echo " make test - Run unit tests (without integration)"
+ @echo " make ci - Run the local fix + verify pipeline"
+ @echo " make build - Build all packages"
+ @echo " make clean - Clean all build artifacts"
+ @echo ""
+ @echo ""
+ @echo "Test Suite Commands:"
+ @echo " make test-unit - Run unit tests (LOW_RESOURCE=1 supported)"
+ @echo " make test-integration - Run integration tests with testcontainers (RUN=, LOW_RESOURCE=1)"
+ @echo " make test-all - Run all tests (unit + integration)"
+ @echo ""
+ @echo ""
+ @echo "Coverage Commands:"
+ @echo " make coverage-unit - Run unit tests with coverage report (PKG=./path, uses .ignorecoverunit)"
+ @echo " make coverage-integration - Run integration tests with coverage report (PKG=./path)"
+ @echo " make coverage - Run all coverage targets (unit + integration)"
+ @echo ""
+ @echo ""
+ @echo "Test Tooling:"
+ @echo " make tools - Install test tools (gotestsum)"
+ @echo ""
+ @echo ""
+ @echo "Code Quality Commands:"
+ @echo " make lint - Run linting on all packages (read-only check)"
+ @echo " make lint-fix - Run linting with auto-fix on all packages"
+ @echo " make format - Format code in all packages"
+ @echo " make tidy - Clean dependencies"
+ @echo " make check-tests - Verify test coverage for packages"
+ @echo " make vet - Run go vet on all packages"
+ @echo " make sec - Run security checks using gosec"
+ @echo " make sec SARIF=1 - Run security checks with SARIF output"
+ @echo ""
+ @echo ""
+ @echo "Git Hook Commands:"
+ @echo " make setup-git-hooks - Install and configure git hooks"
+ @echo " make check-hooks - Verify git hooks installation status"
+ @echo " make check-envs - Check if github hooks are installed and secret env files are not exposed"
+ @echo ""
+ @echo ""
+ @echo "Release Commands:"
+ @echo " make goreleaser - Create release snapshot with goreleaser"
+ @echo ""
+ @echo ""
+
+#-------------------------------------------------------
+# Core Commands
+#-------------------------------------------------------
+
+.PHONY: build
+build:
+ $(call print_title,Building all packages)
+ $(call check_command,go,"Install Go from https://golang.org/doc/install")
+ go build ./...
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) All packages built successfully$(GREEN) ✔️$(NC)"
+
+.PHONY: clean
+clean:
+ $(call print_title,Cleaning build artifacts)
+ @rm -rf ./bin ./dist $(TEST_REPORTS_DIR) coverage.out coverage.html gosec-report.sarif
+ @go clean -cache -testcache
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) All build artifacts cleaned$(GREEN) ✔️$(NC)"
+
+.PHONY: ci
+ci:
+ $(call print_title,Running local CI preflight pipeline)
+ @printf "This target normalizes the worktree before verification.\n"
+ $(MAKE) lint-fix
+ $(MAKE) format
+ $(MAKE) tidy
+ $(MAKE) check-tests
+ $(MAKE) sec
+ $(MAKE) vet
+ $(MAKE) test-unit
+ $(MAKE) test-integration
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) Local CI pipeline completed successfully$(GREEN) ✔️$(NC)"
+
+#-------------------------------------------------------
+# Core Test Commands
+#-------------------------------------------------------
+
+.PHONY: test
+test:
+ $(call print_title,Running all tests)
+ $(call check_command,go,"Install Go from https://golang.org/doc/install")
+ @set -e; mkdir -p $(TEST_REPORTS_DIR); \
+ if [ -n "$(GOTESTSUM)" ]; then \
+ echo "Running tests with gotestsum"; \
+ gotestsum --format testname -- -tags=unit -v $(LOW_RES_P_FLAG) $(LOW_RES_RACE_FLAG) $(LOW_RES_PARALLEL_FLAG) -count=1 $(GO_TEST_LDFLAGS) ./... || { \
+ if [ "$(RETRY_ON_FAIL)" = "1" ]; then \
+ echo "Retrying tests once..."; \
+ gotestsum --format testname -- -tags=unit -v $(LOW_RES_P_FLAG) $(LOW_RES_RACE_FLAG) $(LOW_RES_PARALLEL_FLAG) -count=1 $(GO_TEST_LDFLAGS) ./...; \
+ else \
+ exit 1; \
+ fi; \
+ }; \
+ else \
+ go test -tags=unit -v $(LOW_RES_P_FLAG) $(LOW_RES_RACE_FLAG) $(LOW_RES_PARALLEL_FLAG) -count=1 $(GO_TEST_LDFLAGS) ./... || { \
+ if [ "$(RETRY_ON_FAIL)" = "1" ]; then \
+ echo "Retrying tests once..."; \
+ go test -tags=unit -v $(LOW_RES_P_FLAG) $(LOW_RES_RACE_FLAG) $(LOW_RES_PARALLEL_FLAG) -count=1 $(GO_TEST_LDFLAGS) ./...; \
+ else \
+ exit 1; \
+ fi; \
+ }; \
+ fi
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) All tests passed$(GREEN) ✔️$(NC)"
+
+#-------------------------------------------------------
+# Test Suite Aliases
+#-------------------------------------------------------
+
+.PHONY: test-unit
+test-unit:
+ $(call print_title,Running Go unit tests)
+ $(call check_command,go,"Install Go from https://golang.org/doc/install")
+ @set -e; mkdir -p $(TEST_REPORTS_DIR); \
+ pkgs=$$(go list ./... | grep -v '/tests'); \
+ if [ -z "$$pkgs" ]; then \
+ echo "No unit test packages found"; \
+ else \
+ if [ -n "$(GOTESTSUM)" ]; then \
+ echo "Running unit tests with gotestsum"; \
+ gotestsum --format testname -- -tags=unit -v $(LOW_RES_P_FLAG) $(LOW_RES_RACE_FLAG) $(LOW_RES_PARALLEL_FLAG) -count=1 $(GO_TEST_LDFLAGS) $$pkgs || { \
+ if [ "$(RETRY_ON_FAIL)" = "1" ]; then \
+ echo "Retrying unit tests once..."; \
+ gotestsum --format testname -- -tags=unit -v $(LOW_RES_P_FLAG) $(LOW_RES_RACE_FLAG) $(LOW_RES_PARALLEL_FLAG) -count=1 $(GO_TEST_LDFLAGS) $$pkgs; \
+ else \
+ exit 1; \
+ fi; \
+ }; \
+ else \
+ go test -tags=unit -v $(LOW_RES_P_FLAG) $(LOW_RES_RACE_FLAG) $(LOW_RES_PARALLEL_FLAG) -count=1 $(GO_TEST_LDFLAGS) $$pkgs || { \
+ if [ "$(RETRY_ON_FAIL)" = "1" ]; then \
+ echo "Retrying unit tests once..."; \
+ go test -tags=unit -v $(LOW_RES_P_FLAG) $(LOW_RES_RACE_FLAG) $(LOW_RES_PARALLEL_FLAG) -count=1 $(GO_TEST_LDFLAGS) $$pkgs; \
+ else \
+ exit 1; \
+ fi; \
+ }; \
+ fi; \
+ fi
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) Unit tests passed$(GREEN) ✔️$(NC)"
+
+.PHONY: test-integration
+test-integration:
+ $(call print_title,Running integration tests with testcontainers)
+ $(call check_command,go,"Install Go from https://golang.org/doc/install")
+ $(call check_command,docker,"Install Docker from https://docs.docker.com/get-docker/")
+ @set -e; mkdir -p $(TEST_REPORTS_DIR); \
+ if [ -n "$(PKG)" ]; then \
+ echo "Using specified package: $(PKG)"; \
+ pkgs=$$(go list $(PKG) 2>/dev/null | tr '\n' ' '); \
+ else \
+ echo "Finding packages with *_integration_test.go files..."; \
+ dirs=$$(find . -name '*_integration_test.go' -not -path './vendor/*' -exec dirname {} \; 2>/dev/null | sort -u | tr '\n' ' '); \
+ pkgs=$$(if [ -n "$$dirs" ]; then go list $$dirs 2>/dev/null | tr '\n' ' '; fi); \
+ fi; \
+ if [ -z "$$pkgs" ]; then \
+ echo "No integration test packages found"; \
+ else \
+ echo "Packages: $$pkgs"; \
+ echo "Running packages sequentially (-p=1) to avoid Docker container conflicts"; \
+ if [ "$(LOW_RESOURCE)" = "1" ]; then \
+ echo "LOW_RESOURCE mode: -parallel=1, race detector disabled"; \
+ fi; \
+ if [ -n "$(GOTESTSUM)" ]; then \
+ echo "Running integration tests with gotestsum"; \
+ gotestsum --format testname -- \
+ -tags=integration -v $(LOW_RES_RACE_FLAG) -count=1 -timeout 600s $(GO_TEST_LDFLAGS) \
+ -p 1 $(LOW_RES_PARALLEL_FLAG) \
+ -run '$(RUN_PATTERN)' $$pkgs || { \
+ if [ "$(RETRY_ON_FAIL)" = "1" ]; then \
+ echo "Retrying integration tests once..."; \
+ gotestsum --format testname -- \
+ -tags=integration -v $(LOW_RES_RACE_FLAG) -count=1 -timeout 600s $(GO_TEST_LDFLAGS) \
+ -p 1 $(LOW_RES_PARALLEL_FLAG) \
+ -run '$(RUN_PATTERN)' $$pkgs; \
+ else \
+ exit 1; \
+ fi; \
+ }; \
+ else \
+ go test -tags=integration -v $(LOW_RES_RACE_FLAG) -count=1 -timeout 600s $(GO_TEST_LDFLAGS) \
+ -p 1 $(LOW_RES_PARALLEL_FLAG) \
+ -run '$(RUN_PATTERN)' $$pkgs || { \
+ if [ "$(RETRY_ON_FAIL)" = "1" ]; then \
+ echo "Retrying integration tests once..."; \
+ go test -tags=integration -v $(LOW_RES_RACE_FLAG) -count=1 -timeout 600s $(GO_TEST_LDFLAGS) \
+ -p 1 $(LOW_RES_PARALLEL_FLAG) \
+ -run '$(RUN_PATTERN)' $$pkgs; \
+ else \
+ exit 1; \
+ fi; \
+ }; \
+ fi; \
+ fi
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) Integration tests passed$(GREEN) ✔️$(NC)"
+
+.PHONY: test-all
+test-all:
+ $(call print_title,Running all tests (unit + integration))
+ $(call print_title,Running unit tests)
+ $(MAKE) test-unit
+ $(call print_title,Running integration tests)
+ $(MAKE) test-integration
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) All tests passed$(GREEN) ✔️$(NC)"
+
+#-------------------------------------------------------
+# Coverage Commands
+#-------------------------------------------------------
+
+.PHONY: coverage-unit
+coverage-unit:
+ $(call print_title,Running Go unit tests with coverage)
+ $(call check_command,go,"Install Go from https://golang.org/doc/install")
+ @set -e; mkdir -p $(TEST_REPORTS_DIR); \
+ if [ -n "$(PKG)" ]; then \
+ echo "Using specified package: $(PKG)"; \
+ pkgs=$$(go list $(PKG) 2>/dev/null | grep -v '/tests' | tr '\n' ' '); \
+ else \
+ pkgs=$$(go list ./... | grep -v '/tests'); \
+ fi; \
+ if [ -z "$$pkgs" ]; then \
+ echo "No unit test packages found"; \
+ else \
+ echo "Packages: $$pkgs"; \
+ if [ -n "$(GOTESTSUM)" ]; then \
+ echo "Running unit tests with gotestsum (coverage enabled)"; \
+ gotestsum --format testname -- -tags=unit -v $(LOW_RES_P_FLAG) $(LOW_RES_RACE_FLAG) $(LOW_RES_PARALLEL_FLAG) -count=1 $(GO_TEST_LDFLAGS) -covermode=atomic -coverprofile=$(TEST_REPORTS_DIR)/unit_coverage.out $$pkgs || { \
+ if [ "$(RETRY_ON_FAIL)" = "1" ]; then \
+ echo "Retrying unit tests once..."; \
+ gotestsum --format testname -- -tags=unit -v $(LOW_RES_P_FLAG) $(LOW_RES_RACE_FLAG) $(LOW_RES_PARALLEL_FLAG) -count=1 $(GO_TEST_LDFLAGS) -covermode=atomic -coverprofile=$(TEST_REPORTS_DIR)/unit_coverage.out $$pkgs; \
+ else \
+ exit 1; \
+ fi; \
+ }; \
+ else \
+ go test -tags=unit -v $(LOW_RES_P_FLAG) $(LOW_RES_RACE_FLAG) $(LOW_RES_PARALLEL_FLAG) -count=1 $(GO_TEST_LDFLAGS) -covermode=atomic -coverprofile=$(TEST_REPORTS_DIR)/unit_coverage.out $$pkgs || { \
+ if [ "$(RETRY_ON_FAIL)" = "1" ]; then \
+ echo "Retrying unit tests once..."; \
+ go test -tags=unit -v $(LOW_RES_P_FLAG) $(LOW_RES_RACE_FLAG) $(LOW_RES_PARALLEL_FLAG) -count=1 $(GO_TEST_LDFLAGS) -covermode=atomic -coverprofile=$(TEST_REPORTS_DIR)/unit_coverage.out $$pkgs; \
+ else \
+ exit 1; \
+ fi; \
+ }; \
+ fi; \
+ if [ -f .ignorecoverunit ]; then \
+ echo "Filtering coverage with .ignorecoverunit patterns..."; \
+ patterns=$$(grep -v '^#' .ignorecoverunit | grep -v '^$$' | tr '\n' '|' | sed 's/|$$//'); \
+ if [ -n "$$patterns" ]; then \
+ regex_patterns=$$(echo "$$patterns" | sed 's/[][(){}+?^$$\\|]/\\&/g' | sed 's/\./\\./g' | sed 's/\*/.*/g'); \
+ head -1 $(TEST_REPORTS_DIR)/unit_coverage.out > $(TEST_REPORTS_DIR)/unit_coverage_filtered.out; \
+ tail -n +2 $(TEST_REPORTS_DIR)/unit_coverage.out | grep -vE "$$regex_patterns" >> $(TEST_REPORTS_DIR)/unit_coverage_filtered.out || true; \
+ mv $(TEST_REPORTS_DIR)/unit_coverage_filtered.out $(TEST_REPORTS_DIR)/unit_coverage.out; \
+ echo "Excluded patterns: $$patterns"; \
+ fi; \
+ fi; \
+ echo "----------------------------------------"; \
+ go tool cover -func=$(TEST_REPORTS_DIR)/unit_coverage.out | grep total | awk '{print "Total coverage: " $$3}'; \
+ echo "----------------------------------------"; \
+ fi
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) Unit coverage report generated$(GREEN) ✔️$(NC)"
+
+.PHONY: coverage-integration
+coverage-integration:
+ $(call print_title,Running integration tests with testcontainers (coverage enabled))
+ $(call check_command,go,"Install Go from https://golang.org/doc/install")
+ $(call check_command,docker,"Install Docker from https://docs.docker.com/get-docker/")
+ @set -e; mkdir -p $(TEST_REPORTS_DIR); \
+ if [ -n "$(PKG)" ]; then \
+ echo "Using specified package: $(PKG)"; \
+ pkgs=$$(go list $(PKG) 2>/dev/null | tr '\n' ' '); \
+ else \
+ echo "Finding packages with *_integration_test.go files..."; \
+ dirs=$$(find . -name '*_integration_test.go' -not -path './vendor/*' -exec dirname {} \; 2>/dev/null | sort -u | tr '\n' ' '); \
+ pkgs=$$(if [ -n "$$dirs" ]; then go list $$dirs 2>/dev/null | tr '\n' ' '; fi); \
+ fi; \
+ if [ -z "$$pkgs" ]; then \
+ echo "No integration test packages found"; \
+ else \
+ echo "Packages: $$pkgs"; \
+ echo "Running packages sequentially (-p=1) to avoid Docker container conflicts"; \
+ if [ "$(LOW_RESOURCE)" = "1" ]; then \
+ echo "LOW_RESOURCE mode: -parallel=1, race detector disabled"; \
+ fi; \
+ if [ -n "$(GOTESTSUM)" ]; then \
+ echo "Running testcontainers integration tests with gotestsum (coverage enabled)"; \
+ gotestsum --format testname -- \
+ -tags=integration -v $(LOW_RES_RACE_FLAG) -count=1 -timeout 600s $(GO_TEST_LDFLAGS) \
+ -p 1 $(LOW_RES_PARALLEL_FLAG) \
+ -run '$(RUN_PATTERN)' -covermode=atomic -coverprofile=$(TEST_REPORTS_DIR)/integration_coverage.out \
+ $$pkgs || { \
+ if [ "$(RETRY_ON_FAIL)" = "1" ]; then \
+ echo "Retrying integration tests once..."; \
+ gotestsum --format testname -- \
+ -tags=integration -v $(LOW_RES_RACE_FLAG) -count=1 -timeout 600s $(GO_TEST_LDFLAGS) \
+ -p 1 $(LOW_RES_PARALLEL_FLAG) \
+ -run '$(RUN_PATTERN)' -covermode=atomic -coverprofile=$(TEST_REPORTS_DIR)/integration_coverage.out \
+ $$pkgs; \
+ else \
+ exit 1; \
+ fi; \
+ }; \
+ else \
+ go test -tags=integration -v $(LOW_RES_RACE_FLAG) -count=1 -timeout 600s $(GO_TEST_LDFLAGS) \
+ -p 1 $(LOW_RES_PARALLEL_FLAG) \
+ -run '$(RUN_PATTERN)' -covermode=atomic -coverprofile=$(TEST_REPORTS_DIR)/integration_coverage.out \
+ $$pkgs || { \
+ if [ "$(RETRY_ON_FAIL)" = "1" ]; then \
+ echo "Retrying integration tests once..."; \
+ go test -tags=integration -v $(LOW_RES_RACE_FLAG) -count=1 -timeout 600s $(GO_TEST_LDFLAGS) \
+ -p 1 $(LOW_RES_PARALLEL_FLAG) \
+ -run '$(RUN_PATTERN)' -covermode=atomic -coverprofile=$(TEST_REPORTS_DIR)/integration_coverage.out \
+ $$pkgs; \
+ else \
+ exit 1; \
+ fi; \
+ }; \
+ fi; \
+ echo "----------------------------------------"; \
+ go tool cover -func=$(TEST_REPORTS_DIR)/integration_coverage.out | grep total | awk '{print "Total coverage: " $$3}'; \
+ echo "----------------------------------------"; \
+ fi
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) Integration coverage report generated$(GREEN) ✔️$(NC)"
+
+.PHONY: coverage
+coverage:
+ $(call print_title,Running all coverage targets)
+ $(MAKE) coverage-unit
+ $(MAKE) coverage-integration
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) All coverage reports generated$(GREEN) ✔️$(NC)"
+
+#-------------------------------------------------------
+# Code Quality Commands
+#-------------------------------------------------------
+
+.PHONY: lint
+lint:
+ $(call print_title,Running linters on all packages (read-only))
+ $(call check_command,golangci-lint,"go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)")
+ @golangci-lint run ./...
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) Lint checks passed successfully$(GREEN) ✔️$(NC)"
+
+.PHONY: lint-fix
+lint-fix:
+ $(call print_title,Running linters with auto-fix on all packages)
+ $(call check_command,golangci-lint,"go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)")
+ @golangci-lint run --fix ./...
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) Lint auto-fix completed$(GREEN) ✔️$(NC)"
+
+.PHONY: format
+format:
+ $(call print_title,Formatting code in all packages)
+ $(call check_command,gofmt,"Install Go from https://golang.org/doc/install")
+ @gofmt -w ./
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) All go files formatted$(GREEN) ✔️$(NC)"
+
+.PHONY: check-tests
+check-tests:
+ $(call print_title,Verifying test coverage for packages)
+ @if [ -f "./scripts/check-tests.sh" ]; then \
+ sh ./scripts/check-tests.sh; \
+ else \
+ echo "Running basic test coverage check..."; \
+ go test -cover ./...; \
+ fi
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) Test coverage verification completed$(GREEN) ✔️$(NC)"
+
+.PHONY: vet
+vet:
+ $(call print_title,Running go vet on all packages)
+ $(call check_command,go,"Install Go from https://golang.org/doc/install")
+ go vet ./...
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) go vet completed successfully$(GREEN) ✔️$(NC)"
+
+#-------------------------------------------------------
+# Git Hook Commands
+#-------------------------------------------------------
+
+.PHONY: setup-git-hooks
+setup-git-hooks:
+ $(call print_title,Installing and configuring git hooks)
+ @hooks_dir=$$(git rev-parse --git-path hooks); \
+ if [ ! -d .githooks ]; then \
+ echo "No .githooks directory found, skipping"; \
+ exit 0; \
+ fi; \
+ mkdir -p "$$hooks_dir"; \
+ for hook_dir in .githooks/*/; do \
+ if [ -d "$$hook_dir" ]; then \
+ for FILE in "$$hook_dir"*; do \
+ if [ -f "$$FILE" ]; then \
+ hook_name=$$(basename "$$FILE"); \
+ cp "$$FILE" "$$hooks_dir/$$hook_name"; \
+ chmod +x "$$hooks_dir/$$hook_name"; \
+ fi; \
+ done; \
+ fi; \
+ done
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) All hooks installed and updated$(GREEN) ✔️$(NC)"
+
+.PHONY: check-hooks
+check-hooks:
+ $(call print_title,Verifying git hooks installation status)
+ @hooks_dir=$$(git rev-parse --git-path hooks); \
+ err=0; \
+ for hook_dir in .githooks/*; do \
+ if [ -d "$$hook_dir" ]; then \
+ for FILE in "$$hook_dir"/*; do \
+ if [ -f "$$FILE" ]; then \
+ f=$$(basename -- $$hook_dir)/$$(basename -- $$FILE); \
+ hook_name=$$(basename -- $$FILE); \
+ FILE2=$$hooks_dir/$$hook_name; \
+ if [ -f "$$FILE2" ]; then \
+ if cmp -s "$$FILE" "$$FILE2"; then \
+ echo "$(GREEN)$(BOLD)[ok]$(NC) Hook file $$f installed and updated$(GREEN) ✔️$(NC)"; \
+ else \
+ echo "$(RED)Hook file $$f installed but out-of-date [OUT-OF-DATE] ✗$(NC)"; \
+ err=1; \
+ fi; \
+ else \
+ echo "$(RED)Hook file $$f not installed [NOT INSTALLED] ✗$(NC)"; \
+ err=1; \
+ fi; \
+ fi; \
+ done; \
+ fi; \
+ done; \
+ if [ $$err -ne 0 ]; then \
+ printf "\nRun %smake setup-git-hooks%s to setup your development environment, then try again.\n\n" "$(BOLD)" "$(NC)"; \
+ exit 1; \
+ else \
+ echo "$(GREEN)$(BOLD)[ok]$(NC) All hooks are properly installed$(GREEN) ✔️$(NC)"; \
+ fi
+
+.PHONY: check-envs
+check-envs:
+ $(call print_title,Checking git hooks and environment files for security issues)
+ $(MAKE) check-hooks
+ @echo "Checking for exposed secrets in environment files..."
+ @found=0; \
+ for pattern in '.env' '.env.*' '*.env'; do \
+ files=$$(find . -name "$$pattern" \
+ -not -name '*.example' -not -name '*.sample' -not -name '*.template' \
+ -not -path './vendor/*' -not -path './.git/*' 2>/dev/null); \
+ if [ -n "$$files" ]; then \
+ if echo "$$files" | xargs grep -iqE '^[[:space:]]*(export[[:space:]]+)?[A-Z0-9_]*(SECRET|PASSWORD|TOKEN|API_KEY|PRIVATE_KEY|CREDENTIAL|AWS_ACCESS_KEY|DB_PASS)[A-Z0-9_]*=[[:space:]]*[^#[:space:]]' 2>/dev/null; then \
+ echo "$(RED)Warning: Potential secrets found in environment files:$(NC)"; \
+ echo "$$files" | xargs grep -ilE '^[[:space:]]*(export[[:space:]]+)?[A-Z0-9_]*(SECRET|PASSWORD|TOKEN|API_KEY|PRIVATE_KEY|CREDENTIAL|AWS_ACCESS_KEY|DB_PASS)[A-Z0-9_]*=[[:space:]]*[^#[:space:]]' 2>/dev/null; \
+ found=1; \
+ fi; \
+ fi; \
+ done; \
+ if [ $$found -ne 0 ]; then \
+ echo "$(RED)Make sure these files are in .gitignore and not committed to the repository.$(NC)"; \
+ exit 1; \
+ else \
+ echo "$(GREEN)No exposed secrets found in environment files$(GREEN) ✔️$(NC)"; \
+ fi
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) Environment check completed$(GREEN) ✔️$(NC)"
+
+#-------------------------------------------------------
+# Development Commands
+#-------------------------------------------------------
+
+.PHONY: tidy
+tidy:
+ $(call print_title,Cleaning dependencies)
+ $(call check_command,go,"Install Go from https://golang.org/doc/install")
+ go mod tidy
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) Dependencies cleaned successfully$(GREEN) ✔️$(NC)"
+
+SARIF ?= 0
+
+.PHONY: sec
+sec:
+ $(call print_title,Running security checks using gosec)
+ @if ! command -v gosec >/dev/null 2>&1; then \
+ echo "Installing gosec..."; \
+ go install github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION); \
+ fi
+ @if find . -name "*.go" -type f -not -path './vendor/*' | grep -q .; then \
+ echo "Running security checks on all packages..."; \
+ if [ "$(SARIF)" = "1" ]; then \
+ echo "Generating SARIF output: gosec-report.sarif"; \
+ if "$(GOSEC)" -fmt sarif -out gosec-report.sarif ./...; then \
+ echo "$(GREEN)$(BOLD)[ok]$(NC) SARIF report generated: gosec-report.sarif$(GREEN) ✔️$(NC)"; \
+ else \
+ printf "\n%s%sSecurity issues found by gosec. Please address them before proceeding.%s\n\n" "$(BOLD)" "$(RED)" "$(NC)"; \
+ echo "SARIF report with details: gosec-report.sarif"; \
+ exit 1; \
+ fi; \
+ else \
+ if "$(GOSEC)" ./...; then \
+ echo "$(GREEN)$(BOLD)[ok]$(NC) Security checks completed$(GREEN) ✔️$(NC)"; \
+ else \
+ printf "\n%s%sSecurity issues found by gosec. Please address them before proceeding.%s\n\n" "$(BOLD)" "$(RED)" "$(NC)"; \
+ exit 1; \
+ fi; \
+ fi; \
+ else \
+ echo "No Go files found, skipping security checks"; \
+ fi
+
+#-------------------------------------------------------
+# Release Commands
+#-------------------------------------------------------
+
+.PHONY: goreleaser
+goreleaser:
+ $(call print_title,Creating release snapshot with goreleaser)
+ $(call check_command,goreleaser,"go install github.com/goreleaser/goreleaser@latest")
+ goreleaser release --snapshot --skip=publish --clean
+ @echo "$(GREEN)$(BOLD)[ok]$(NC) Release snapshot created successfully$(GREEN) ✔️$(NC)"
diff --git a/README.md b/README.md
index 1e05864..7d62742 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,49 @@
# lib-observability
-Lerian's shared Go library for OpenTelemetry-based observability — tracing, metrics, structured logging, panic recovery telemetry, and production assertions with instrumentation.
+
+`lib-observability` is Lerian's shared Go library for observability and telemetry. It provides a unified, OpenTelemetry-native instrumentation layer for tracing, metrics, structured logging, panic recovery with telemetry, and production-safe assertions — extracted from [`lib-commons`](https://github.com/LerianStudio/lib-commons) to give observability its own versioning, dependency footprint, and release cadence.
+
+## What's in this library
+
+### Telemetry bootstrap and tracing (`tracing`)
+
+Full OpenTelemetry SDK lifecycle management: OTLP/gRPC exporter setup for traces, metrics, and logs; `TracerProvider`, `MeterProvider`, and `LoggerProvider` construction via a single `NewTelemetry(cfg)` call; global provider opt-in with `ApplyGlobals()`; and graceful shutdown with `ShutdownTelemetry()`. Includes trace context propagation for HTTP, gRPC, and message queues (Kafka/Redpanda/RabbitMQ), span error/event recording helpers, struct-to-attribute conversion with automatic sensitive field redaction, and custom `SpanProcessor` implementations for context-carried attribute injection.
+
+### Metrics (`metrics`)
+
+Thread-safe `MetricsFactory` with lazy instrument caching and a fluent builder API for Counters, Gauges, and Histograms. Provides `.WithLabels()` / `.WithAttributes()` chaining followed by `.Add()`, `.Set()`, or `.Record()` — all with explicit error returns. Includes pre-configured domain metric recorders (accounts, transactions, routes, operations) and system infrastructure gauges (CPU, memory). Ships a `NewNopFactory()` for tests and disabled-metrics environments.
+
+### Structured logging (`log`)
+
+A minimal, implementation-agnostic `Logger` interface with five methods (`Log`, `With`, `WithGroup`, `Enabled`, `Sync`), four severity levels, and typed `Field` constructors (`String`, `Int`, `Bool`, `Err`, `Any`). Includes a stdlib-based `GoLogger` with CWE-117 log-injection prevention, a `NopLogger` for tests, production-aware error sanitization (`SafeError`, `SanitizeExternalResponse`), and a generated mock for unit testing.
+
+### Zap adapter with OTEL bridge (`zap`)
+
+A [`zap`](https://github.com/uber-go/zap) adapter implementing the `Logger` interface, with automatic `trace_id` and `span_id` injection into every log entry. Bridges zap output to the OpenTelemetry Logs SDK via `otelzap`, enabling unified log collection through the OTLP pipeline. Supports environment-aware configuration (production, staging, development, local) and runtime log level adjustment.
+
+### Panic recovery with telemetry (`runtime`)
+
+Policy-driven panic recovery (`KeepRunning` / `CrashProcess`) with full observability integration: span event recording (`panic.recovered`), panic counter metrics (`panic_recovered_total`), structured logging, and optional external error reporter forwarding. Provides safe goroutine launchers (`SafeGo`, `SafeGoWithContext`) and `HandlePanicValue` for integration with HTTP/gRPC framework recovery middleware. Supports production mode for stack trace redaction.
+
+### Production assertions with instrumentation (`assert`)
+
+A context-scoped `Asserter` that validates domain invariants at runtime without panicking — every assertion failure returns an error, records a span event (`assertion.failed`), and increments the `assertion_failed_total` metric counter. Includes a predicates library for financial domain validation (decimal precision, balance sufficiency, transaction state transitions, debit/credit equality) alongside general-purpose checks (`NotNil`, `NotEmpty`, `NoError`, `ValidUUID`).
+
+### Observability constants and context carriers
+
+Shared OTEL attribute prefixes, metric names, event names, header constants (`traceparent`, `Traceparent`, `Tracestate`), label sanitization (`SanitizeMetricLabel`), and sensitive field detection for cross-cutting redaction. Context carrier helpers (`ContextWithTracer`, `ContextWithMetricFactory`, `ContextWithLogger`, `ContextWithSpanAttributes`) for propagating observability primitives through `context.Context`.
+
+### Redaction engine
+
+A configurable `Redactor` with rule-based field processing supporting mask, hash (SHA-256), and drop actions. Applies automatically to span attributes via the `RedactingAttrBagSpanProcessor` and to struct-to-attribute conversion. Includes `ObfuscateStruct` for generic struct field obfuscation and integration with the sensitive field detection layer.
+
+## Design principles
+
+- **Explicit initialization** — no implicit global state; `NewTelemetry` + `ApplyGlobals` is opt-in
+- **Nil-safe and no-op by default** — every factory and logger has a null-object variant for safe degradation
+- **Errors over panics** — metric/builder operations return errors; assertions return errors instead of panicking
+- **Redaction-first** — sensitive fields are masked in spans, logs, and attributes by default
+- **Interface-driven** — `Logger`, `MetricsFactory`, `ErrorReporter`, and `DLQMetrics` are all interface-bound for testability
+
+## Relationship to lib-commons
+
+This library was extracted from `lib-commons` to decouple observability infrastructure from service primitives and data connectors. Services that previously imported `lib-commons` for telemetry can migrate to `lib-observability` for a lighter dependency graph. `lib-commons` will depend on `lib-observability` for its own instrumentation needs (database spans, streaming metrics, middleware telemetry).
diff --git a/assert/assert.go b/assert/assert.go
new file mode 100644
index 0000000..6502d6a
--- /dev/null
+++ b/assert/assert.go
@@ -0,0 +1,511 @@
+package assert
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "reflect"
+ goruntime "runtime"
+ "runtime/debug"
+ "strconv"
+ "strings"
+ "sync"
+
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/codes"
+ "go.opentelemetry.io/otel/trace"
+
+ constant "github.com/LerianStudio/lib-observability/constants"
+ "github.com/LerianStudio/lib-observability/log"
+ "github.com/LerianStudio/lib-observability/metrics"
+ "github.com/LerianStudio/lib-observability/runtime"
+)
+
+// Logger defines the minimal logging interface required by assertions.
+// This interface is satisfied by github.com/LerianStudio/lib-observability/log.Logger.
+type Logger interface {
+ Log(ctx context.Context, level log.Level, msg string, fields ...log.Field)
+}
+
+// Asserter evaluates invariants and emits telemetry on failure.
+type Asserter struct {
+ ctx context.Context
+ logger Logger
+ component string
+ operation string
+}
+
+// ErrAssertionFailed is the sentinel error for failed assertions.
+var ErrAssertionFailed = errors.New("assertion failed")
+
+// AssertionError represents a failed assertion with rich context.
+type AssertionError struct {
+ Assertion string
+ Message string
+ Component string
+ Operation string
+ Details string
+}
+
+// Error returns the formatted assertion failure message.
+func (entry *AssertionError) Error() string {
+ if entry == nil {
+ return ErrAssertionFailed.Error()
+ }
+
+ if entry.Details == "" {
+ return "assertion failed: " + entry.Message
+ }
+
+ return "assertion failed: " + entry.Message + "\n" + entry.Details
+}
+
+// Unwrap returns the sentinel assertion error for errors.Is.
+func (entry *AssertionError) Unwrap() error {
+ return ErrAssertionFailed
+}
+
+// New creates an Asserter with context, logging, and labels.
+// component and operation are used for telemetry labeling.
+//
+//nolint:contextcheck // Intentionally creates a fallback context when nil is passed
+func New(ctx context.Context, logger Logger, component, operation string) *Asserter {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ return &Asserter{
+ ctx: ctx,
+ logger: logger,
+ component: component,
+ operation: operation,
+ }
+}
+
+// That returns an error if ok is false. Use for general-purpose assertions.
+//
+// Example:
+//
+// if err := asserter.That(ctx, len(items) > 0, "items must not be empty", "count", len(items)); err != nil {
+// return err
+// }
+func (asserter *Asserter) That(ctx context.Context, ok bool, msg string, kv ...any) error {
+ if ok {
+ return nil
+ }
+
+ return asserter.fail(ctx, "That", msg, kv...)
+}
+
+// NotNil returns an error if v is nil. This function correctly handles both untyped nil
+// and typed nil (nil interface values with concrete types).
+//
+// Example:
+//
+// if err := asserter.NotNil(ctx, config, "config must be initialized"); err != nil {
+// return err
+// }
+func (asserter *Asserter) NotNil(ctx context.Context, v any, msg string, kv ...any) error {
+ if !isNil(v) {
+ return nil
+ }
+
+ return asserter.fail(ctx, "NotNil", msg, kv...)
+}
+
+// NotEmpty returns an error if s is an empty string.
+//
+// Example:
+//
+// if err := asserter.NotEmpty(ctx, userID, "userID must be provided"); err != nil {
+// return err
+// }
+func (asserter *Asserter) NotEmpty(ctx context.Context, s, msg string, kv ...any) error {
+ if s != "" {
+ return nil
+ }
+
+ return asserter.fail(ctx, "NotEmpty", msg, kv...)
+}
+
+// NoError returns an error if err is not nil. The error message and type are
+// automatically included in the assertion context for debugging.
+//
+// Example:
+//
+// if err := asserter.NoError(ctx, err, "compute must succeed", "input", input); err != nil {
+// return err
+// }
+func (asserter *Asserter) NoError(ctx context.Context, err error, msg string, kv ...any) error {
+ if err == nil {
+ return nil
+ }
+
+ // Prepend error and error_type to key-value pairs for richer debugging
+ // errorKVPairs: 2 pairs added (error + error_type), each pair = 2 elements
+ const errorKVPairs = 4
+
+ kvWithError := make([]any, 0, len(kv)+errorKVPairs)
+ kvWithError = append(kvWithError, "error", err.Error())
+ kvWithError = append(kvWithError, "error_type", fmt.Sprintf("%T", err))
+ kvWithError = append(kvWithError, kv...)
+
+ return asserter.fail(ctx, "NoError", msg, kvWithError...)
+}
+
+// Never always returns an error. Use for code paths that should be unreachable.
+//
+// Example:
+//
+// return asserter.Never(ctx, "unhandled status", "status", status)
+func (asserter *Asserter) Never(ctx context.Context, msg string, kv ...any) error {
+ return asserter.fail(ctx, "Never", msg, kv...)
+}
+
+// Halt terminates the current goroutine if err is not nil.
+// Use this after a failed assertion in goroutines to prevent further execution.
+func (asserter *Asserter) Halt(err error) {
+ if err != nil {
+ goruntime.Goexit()
+ }
+}
+
+const maxValueLength = 200 // Truncate values longer than this
+
+// truncateValue truncates long values for logging safety.
+// This prevents log bloat and reduces risk of sensitive data exposure.
+func truncateValue(v any) string {
+ s := fmt.Sprintf("%v", v)
+ if len(s) <= maxValueLength {
+ return s
+ }
+
+ return s[:maxValueLength] + "... (truncated " + strconv.Itoa(len(s)-maxValueLength) + " chars)"
+}
+
+func (asserter *Asserter) fail(ctx context.Context, assertion, msg string, kv ...any) error {
+ ctx, logger, component, operation := asserter.values(ctx)
+ contextPairs := withContextPairs(assertion, component, operation, kv)
+ details := formatKeyValueLines(contextPairs)
+
+ stack := []byte(nil)
+ if shouldIncludeStack() {
+ stack = debug.Stack()
+ }
+
+ // Emit structured fields for log aggregation; fall back to single-string format
+ // when no logger is available (stderr path) or for the stack trace supplement.
+ logAssertionStructured(logger, assertion, component, operation, msg, details)
+
+ if len(stack) > 0 && logger != nil {
+ logger.Log(context.Background(), log.LevelError, "assertion stack trace",
+ log.String("assertion_type", assertion),
+ log.String("stack_trace", string(stack)),
+ )
+ }
+
+ recordAssertionObservability(ctx, assertion, msg, stack, component, operation)
+
+ return &AssertionError{
+ Assertion: assertion,
+ Message: msg,
+ Component: component,
+ Operation: operation,
+ Details: details,
+ }
+}
+
+func (asserter *Asserter) values(ctx context.Context) (context.Context, Logger, string, string) {
+ if asserter == nil {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ return ctx, nil, "", ""
+ }
+
+ if ctx == nil {
+ ctx = asserter.ctx
+ }
+
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ return ctx, asserter.logger, asserter.component, asserter.operation
+}
+
+// shouldIncludeStack controls whether assertion failures include a stack trace.
+//
+// Stack traces are opt-out: they are included by default and suppressed when
+// production mode is detected. This is intentional because during development
+// and testing, stack traces are invaluable for debugging assertion failures,
+// while in production they add noise and may expose internal paths.
+//
+// To disable stack traces in production, use either:
+// - runtime.SetProductionMode(true) during application startup (preferred)
+// - Set ENV=production or GO_ENV=production environment variables
+func shouldIncludeStack() bool {
+ // Primary check: use runtime.IsProductionMode() which is explicitly
+ // set during application startup via runtime.SetProductionMode(true).
+ if runtime.IsProductionMode() {
+ return false
+ }
+
+ // Fallback: check environment variables for cases where production mode
+ // has not been explicitly configured via the runtime package.
+ env := strings.TrimSpace(os.Getenv("ENV"))
+ goEnv := strings.TrimSpace(os.Getenv("GO_ENV"))
+
+ return !strings.EqualFold(env, "production") && !strings.EqualFold(goEnv, "production")
+}
+
+// contextPairsCapacity is the capacity for the fixed context pairs (assertion, component, operation).
+const contextPairsCapacity = 6
+
+func withContextPairs(assertion, component, operation string, kv []any) []any {
+ contextPairs := make([]any, 0, len(kv)+contextPairsCapacity)
+ contextPairs = append(contextPairs, "assertion", assertion)
+
+ if component != "" {
+ contextPairs = append(contextPairs, "component", component)
+ }
+
+ if operation != "" {
+ contextPairs = append(contextPairs, "operation", operation)
+ }
+
+ contextPairs = append(contextPairs, kv...)
+
+ return contextPairs
+}
+
+func formatKeyValueLines(kv []any) string {
+ if len(kv) == 0 {
+ return ""
+ }
+
+ var sb strings.Builder
+
+ for i := 0; i < len(kv); i += 2 {
+ if i > 0 {
+ sb.WriteString("\n")
+ }
+
+ var value any
+ if i+1 < len(kv) {
+ value = kv[i+1]
+ } else {
+ value = "MISSING_VALUE"
+ }
+
+ fmt.Fprintf(&sb, " %v=%v", kv[i], truncateValue(value))
+ }
+
+ return sb.String()
+}
+
+// logAssertionStructured emits assertion failure as individual structured log fields
+// for better searchability in log aggregation systems (Loki, Elasticsearch, etc.).
+func logAssertionStructured(logger Logger, assertion, component, operation, msg, details string) {
+ if logger == nil {
+ // Fall back to stderr for emergency visibility
+ fmt.Fprintln(os.Stderr, "ASSERTION FAILED: "+msg)
+
+ return
+ }
+
+ fields := []log.Field{
+ log.String("assertion_type", assertion),
+ log.String("message", msg),
+ }
+
+ if component != "" {
+ fields = append(fields, log.String("component", component))
+ }
+
+ if operation != "" {
+ fields = append(fields, log.String("operation", operation))
+ }
+
+ if details != "" {
+ fields = append(fields, log.String("details", details))
+ }
+
+ logger.Log(context.Background(), log.LevelError, "ASSERTION FAILED", fields...)
+}
+
+// logAssertion is kept for backward compatibility with code paths that only
+// have a pre-formatted message string (e.g. when logger is nil and we write to stderr).
+func logAssertion(logger Logger, message string) {
+ if logger != nil {
+ logger.Log(context.Background(), log.LevelError, message)
+ return
+ }
+
+ fmt.Fprintln(os.Stderr, message)
+}
+
+// isNil checks if a value is nil, handling both untyped nil and typed nil
+// (nil interface values with concrete types).
+func isNil(v any) bool {
+ if v == nil {
+ return true
+ }
+
+ rv := reflect.ValueOf(v)
+ switch rv.Kind() {
+ case reflect.Pointer, reflect.Interface, reflect.Slice, reflect.Map, reflect.Chan, reflect.Func:
+ return rv.IsNil()
+ default:
+ return false
+ }
+}
+
+// AssertionSpanEventName is the event name used when recording assertion failures on spans.
+const AssertionSpanEventName = constant.EventAssertionFailed
+
+// AssertionMetrics provides assertion-related metrics using OpenTelemetry.
+// It wraps lib-observability's MetricsFactory for consistent metric handling.
+type AssertionMetrics struct {
+ factory *metrics.MetricsFactory
+}
+
+// assertionFailedMetric defines the metric for counting failed assertions.
+var assertionFailedMetric = metrics.Metric{
+ Name: constant.MetricAssertionFailedTotal,
+ Unit: "1",
+ Description: "Total number of failed assertions",
+}
+
+var (
+ assertionMetricsInstance *AssertionMetrics
+ assertionMetricsMu sync.RWMutex
+)
+
+// InitAssertionMetrics initializes assertion metrics with the provided MetricsFactory.
+// This should be called once during application startup after telemetry is initialized.
+func InitAssertionMetrics(factory *metrics.MetricsFactory) {
+ assertionMetricsMu.Lock()
+ defer assertionMetricsMu.Unlock()
+
+ if factory == nil {
+ return
+ }
+
+ if assertionMetricsInstance != nil {
+ return
+ }
+
+ assertionMetricsInstance = &AssertionMetrics{factory: factory}
+}
+
+// GetAssertionMetrics returns the singleton AssertionMetrics instance.
+// Returns nil if InitAssertionMetrics has not been called.
+func GetAssertionMetrics() *AssertionMetrics {
+ assertionMetricsMu.RLock()
+ defer assertionMetricsMu.RUnlock()
+
+ return assertionMetricsInstance
+}
+
+// ResetAssertionMetrics clears the assertion metrics singleton (useful for tests).
+func ResetAssertionMetrics() {
+ assertionMetricsMu.Lock()
+ defer assertionMetricsMu.Unlock()
+
+ assertionMetricsInstance = nil
+}
+
+// RecordAssertionFailed increments the assertion_failed_total counter with labels.
+// If metrics are not initialized, this is a no-op.
+func (am *AssertionMetrics) RecordAssertionFailed(
+ ctx context.Context,
+ component, operation, assertion string,
+) {
+ if am == nil || am.factory == nil {
+ return
+ }
+
+ counter, err := am.factory.Counter(assertionFailedMetric)
+ if err != nil {
+ logAssertion(nil, fmt.Sprintf("failed to create assertion metric counter: %v", err))
+ return
+ }
+
+ err = counter.
+ WithLabels(map[string]string{
+ "component": constant.SanitizeMetricLabel(component),
+ "operation": constant.SanitizeMetricLabel(operation),
+ "assertion": constant.SanitizeMetricLabel(assertion),
+ }).
+ AddOne(ctx)
+ if err != nil {
+ logAssertion(nil, fmt.Sprintf("failed to record assertion metric: %v", err))
+ return
+ }
+}
+
+func recordAssertionMetric(ctx context.Context, component, operation, assertion string) {
+ am := GetAssertionMetrics()
+ if am != nil {
+ am.RecordAssertionFailed(ctx, component, operation, assertion)
+ }
+}
+
+func recordAssertionObservability(
+ ctx context.Context,
+ assertion, message string,
+ stack []byte,
+ component, operation string,
+) {
+ recordAssertionMetric(ctx, component, operation, assertion)
+ recordAssertionToSpan(ctx, assertion, message, stack, component, operation)
+}
+
+func recordAssertionToSpan(
+ ctx context.Context,
+ assertion, message string,
+ stack []byte,
+ component, operation string,
+) {
+ span := trace.SpanFromContext(ctx)
+ if !span.IsRecording() {
+ return
+ }
+
+ attrs := []attribute.KeyValue{
+ attribute.String("assertion.name", assertion),
+ attribute.String("assertion.message", message),
+ }
+
+ if component != "" {
+ attrs = append(attrs, attribute.String("assertion.component", component))
+ }
+
+ if operation != "" {
+ attrs = append(attrs, attribute.String("assertion.operation", operation))
+ }
+
+ if len(stack) > 0 {
+ attrs = append(attrs, attribute.String("assertion.stack", string(stack)))
+ }
+
+ span.AddEvent(AssertionSpanEventName, trace.WithAttributes(attrs...))
+ span.RecordError(fmt.Errorf("%w: %s", ErrAssertionFailed, message))
+ span.SetStatus(codes.Error, assertionStatusMessage(component, operation))
+}
+
+func assertionStatusMessage(component, operation string) string {
+ switch {
+ case component != "" && operation != "":
+ return fmt.Sprintf("assertion failed in %s/%s", component, operation)
+ case component != "":
+ return "assertion failed in " + component
+ case operation != "":
+ return "assertion failed in " + operation
+ default:
+ return "assertion failed"
+ }
+}
diff --git a/assert/assert_extended_test.go b/assert/assert_extended_test.go
new file mode 100644
index 0000000..a657867
--- /dev/null
+++ b/assert/assert_extended_test.go
@@ -0,0 +1,585 @@
+//go:build unit
+
+package assert
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ constant "github.com/LerianStudio/lib-observability/constants"
+ "github.com/LerianStudio/lib-observability/log"
+ libRuntime "github.com/LerianStudio/lib-observability/runtime"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel/metric/noop"
+ tracesdk "go.opentelemetry.io/otel/sdk/trace"
+
+ "github.com/LerianStudio/lib-observability/metrics"
+)
+
+func newTestMetricsFactory(t *testing.T) *metrics.MetricsFactory {
+ t.Helper()
+
+ meter := noop.NewMeterProvider().Meter("test")
+ factory, err := metrics.NewMetricsFactory(meter, &log.NopLogger{})
+ require.NoError(t, err, "newTestMetricsFactory failed")
+
+ return factory
+}
+
+// --- AssertionError Tests ---
+
+func TestAssertionError_NilReceiver(t *testing.T) {
+ t.Parallel()
+
+ var entry *AssertionError
+ msg := entry.Error()
+ require.Equal(t, ErrAssertionFailed.Error(), msg)
+}
+
+func TestAssertionError_WithoutDetails(t *testing.T) {
+ t.Parallel()
+
+ entry := &AssertionError{
+ Assertion: "That",
+ Message: "some message",
+ Component: "comp",
+ Operation: "op",
+ Details: "",
+ }
+
+ msg := entry.Error()
+ require.Equal(t, "assertion failed: some message", msg)
+}
+
+func TestAssertionError_WithDetails(t *testing.T) {
+ t.Parallel()
+
+ entry := &AssertionError{
+ Assertion: "NotNil",
+ Message: "value required",
+ Component: "comp",
+ Operation: "op",
+ Details: " key=value",
+ }
+
+ msg := entry.Error()
+ require.Contains(t, msg, "assertion failed: value required")
+ require.Contains(t, msg, "key=value")
+}
+
+func TestAssertionError_Unwrap(t *testing.T) {
+ t.Parallel()
+
+ entry := &AssertionError{Message: "test"}
+ require.ErrorIs(t, entry, ErrAssertionFailed)
+}
+
+// --- Halt Tests ---
+
+func TestHalt_NilError_NoEffect(t *testing.T) {
+ t.Parallel()
+
+ asserter := New(context.Background(), nil, "test", "halt")
+ asserter.Halt(nil)
+}
+
+// --- truncateValue Tests ---
+
+func TestTruncateValue_ShortValue(t *testing.T) {
+ t.Parallel()
+
+ result := truncateValue("hello")
+ require.Equal(t, "hello", result)
+}
+
+func TestTruncateValue_ExactMaxLength(t *testing.T) {
+ t.Parallel()
+
+ val := strings.Repeat("a", maxValueLength)
+ result := truncateValue(val)
+ require.Equal(t, val, result)
+}
+
+func TestTruncateValue_LongValue(t *testing.T) {
+ t.Parallel()
+
+ val := strings.Repeat("b", maxValueLength+50)
+ result := truncateValue(val)
+ require.Len(t, result, maxValueLength+len("... (truncated 50 chars)"))
+ require.Contains(t, result, "... (truncated 50 chars)")
+}
+
+func TestTruncateValue_NonStringType(t *testing.T) {
+ t.Parallel()
+
+ result := truncateValue(42)
+ require.Equal(t, "42", result)
+}
+
+// --- values Tests ---
+
+func TestValues_NilAsserter(t *testing.T) {
+ t.Parallel()
+
+ var asserter *Asserter
+ ctx, logger, component, operation := asserter.values(context.Background())
+ require.NotNil(t, ctx)
+ require.Nil(t, logger)
+ require.Empty(t, component)
+ require.Empty(t, operation)
+}
+
+func TestValues_NilAsserterNilCtx(t *testing.T) {
+ t.Parallel()
+
+ var asserter *Asserter
+ //nolint:staticcheck // intentionally passing nil ctx
+ ctx, _, _, _ := asserter.values(nil)
+ require.NotNil(t, ctx)
+}
+
+func TestValues_WithAsserterNilCtx(t *testing.T) {
+ t.Parallel()
+
+ logger := &testLogger{}
+ asserter := New(context.Background(), logger, "comp", "op")
+ //nolint:staticcheck // intentionally passing nil ctx
+ ctx, l, c, o := asserter.values(nil)
+ require.NotNil(t, ctx)
+ require.Equal(t, logger, l)
+ require.Equal(t, "comp", c)
+ require.Equal(t, "op", o)
+}
+
+func TestValues_BothNilFallsToBackground(t *testing.T) {
+ t.Parallel()
+
+ asserter := &Asserter{
+ ctx: nil,
+ logger: nil,
+ component: "",
+ operation: "",
+ }
+ //nolint:staticcheck // intentionally passing nil ctx
+ ctx, _, _, _ := asserter.values(nil)
+ require.NotNil(t, ctx)
+}
+
+// --- SanitizeMetricLabel Tests ---
+
+func TestSanitizeMetricLabel_ShortLabel(t *testing.T) {
+ t.Parallel()
+
+ result := constant.SanitizeMetricLabel("short")
+ require.Equal(t, "short", result)
+}
+
+func TestSanitizeMetricLabel_ExactMaxLength(t *testing.T) {
+ t.Parallel()
+
+ val := strings.Repeat("x", constant.MaxMetricLabelLength)
+ result := constant.SanitizeMetricLabel(val)
+ require.Equal(t, val, result)
+}
+
+func TestSanitizeMetricLabel_TruncatesLongLabel(t *testing.T) {
+ t.Parallel()
+
+ val := strings.Repeat("y", constant.MaxMetricLabelLength+20)
+ result := constant.SanitizeMetricLabel(val)
+ require.Len(t, result, constant.MaxMetricLabelLength)
+ require.Equal(t, strings.Repeat("y", constant.MaxMetricLabelLength), result)
+}
+
+// --- assertionStatusMessage Tests ---
+
+func TestAssertionStatusMessage_ComponentAndOperation(t *testing.T) {
+ t.Parallel()
+
+ msg := assertionStatusMessage("comp", "op")
+ require.Equal(t, "assertion failed in comp/op", msg)
+}
+
+func TestAssertionStatusMessage_ComponentOnly(t *testing.T) {
+ t.Parallel()
+
+ msg := assertionStatusMessage("comp", "")
+ require.Equal(t, "assertion failed in comp", msg)
+}
+
+func TestAssertionStatusMessage_OperationOnly(t *testing.T) {
+ t.Parallel()
+
+ msg := assertionStatusMessage("", "op")
+ require.Equal(t, "assertion failed in op", msg)
+}
+
+func TestAssertionStatusMessage_Neither(t *testing.T) {
+ t.Parallel()
+
+ msg := assertionStatusMessage("", "")
+ require.Equal(t, "assertion failed", msg)
+}
+
+// --- InitAssertionMetrics / ResetAssertionMetrics / GetAssertionMetrics Tests ---
+
+func TestInitAssertionMetrics_NilFactory(t *testing.T) {
+ // Not parallel - modifies global state.
+ ResetAssertionMetrics()
+ defer ResetAssertionMetrics()
+
+ InitAssertionMetrics(nil)
+ require.Nil(t, GetAssertionMetrics())
+}
+
+func TestInitAssertionMetrics_ValidFactory(t *testing.T) {
+ // Not parallel - modifies global state.
+ ResetAssertionMetrics()
+ defer ResetAssertionMetrics()
+
+ factory := newTestMetricsFactory(t)
+ InitAssertionMetrics(factory)
+
+ am := GetAssertionMetrics()
+ require.NotNil(t, am)
+ require.Equal(t, factory, am.factory)
+}
+
+func TestInitAssertionMetrics_DoubleInit_NoOverwrite(t *testing.T) {
+ // Not parallel - modifies global state.
+ ResetAssertionMetrics()
+ defer ResetAssertionMetrics()
+
+ factory1 := newTestMetricsFactory(t)
+ factory2 := newTestMetricsFactory(t)
+
+ InitAssertionMetrics(factory1)
+ InitAssertionMetrics(factory2)
+
+ am := GetAssertionMetrics()
+ require.NotNil(t, am)
+ require.Equal(t, factory1, am.factory, "second init should not overwrite")
+}
+
+func TestResetAssertionMetrics(t *testing.T) {
+ // Not parallel - modifies global state.
+ factory := newTestMetricsFactory(t)
+ InitAssertionMetrics(factory)
+
+ ResetAssertionMetrics()
+ require.Nil(t, GetAssertionMetrics())
+}
+
+// --- RecordAssertionFailed Tests ---
+
+func TestRecordAssertionFailed_NilMetrics(t *testing.T) {
+ t.Parallel()
+
+ var am *AssertionMetrics
+ am.RecordAssertionFailed(context.Background(), "comp", "op", "That")
+}
+
+func TestRecordAssertionFailed_NilFactory(t *testing.T) {
+ t.Parallel()
+
+ am := &AssertionMetrics{factory: nil}
+ am.RecordAssertionFailed(context.Background(), "comp", "op", "That")
+}
+
+func TestRecordAssertionFailed_WithFactory(t *testing.T) {
+ // Not parallel - modifies global state.
+ ResetAssertionMetrics()
+ defer ResetAssertionMetrics()
+
+ factory := newTestMetricsFactory(t)
+ InitAssertionMetrics(factory)
+
+ am := GetAssertionMetrics()
+ require.NotNil(t, am)
+ am.RecordAssertionFailed(context.Background(), "comp", "op", "That")
+}
+
+// --- recordAssertionMetric Tests ---
+
+func TestRecordAssertionMetric_NoMetricsInitialized(t *testing.T) {
+ // Not parallel - modifies global state.
+ ResetAssertionMetrics()
+ defer ResetAssertionMetrics()
+
+ recordAssertionMetric(context.Background(), "comp", "op", "That")
+}
+
+func TestRecordAssertionMetric_WithMetrics(t *testing.T) {
+ // Not parallel - modifies global state.
+ ResetAssertionMetrics()
+ defer ResetAssertionMetrics()
+
+ factory := newTestMetricsFactory(t)
+ InitAssertionMetrics(factory)
+
+ recordAssertionMetric(context.Background(), "comp", "op", "NotNil")
+}
+
+// --- recordAssertionToSpan Tests ---
+
+func TestRecordAssertionToSpan_NoSpanInContext(t *testing.T) {
+ t.Parallel()
+
+ recordAssertionToSpan(context.Background(), "That", "test message", nil, "comp", "op")
+}
+
+func TestRecordAssertionToSpan_WithRecordingSpan(t *testing.T) {
+ t.Parallel()
+
+ tp := tracesdk.NewTracerProvider()
+ tracer := tp.Tracer("test")
+ ctx, span := tracer.Start(context.Background(), "test-span")
+ defer span.End()
+
+ recordAssertionToSpan(ctx, "NotNil", "value is nil", nil, "comp", "op")
+}
+
+func TestRecordAssertionToSpan_WithStack(t *testing.T) {
+ t.Parallel()
+
+ tp := tracesdk.NewTracerProvider()
+ tracer := tp.Tracer("test")
+ ctx, span := tracer.Start(context.Background(), "test-span")
+ defer span.End()
+
+ stack := []byte("goroutine 1:\n main.go:10")
+ recordAssertionToSpan(ctx, "That", "condition false", stack, "comp", "op")
+}
+
+func TestRecordAssertionToSpan_EmptyComponentAndOperation(t *testing.T) {
+ t.Parallel()
+
+ tp := tracesdk.NewTracerProvider()
+ tracer := tp.Tracer("test")
+ ctx, span := tracer.Start(context.Background(), "test-span")
+ defer span.End()
+
+ recordAssertionToSpan(ctx, "Never", "unreachable", nil, "", "")
+}
+
+// --- logAssertion Tests ---
+
+func TestLogAssertion_WithNilLogger(t *testing.T) {
+ t.Parallel()
+
+ logAssertion(nil, "test message for stderr")
+}
+
+func TestLogAssertion_WithLogger(t *testing.T) {
+ t.Parallel()
+
+ logger := &testLogger{}
+ logAssertion(logger, "test message for logger")
+ require.Len(t, logger.messages, 1)
+ require.Equal(t, "test message for logger", logger.messages[0])
+}
+
+// --- New Tests ---
+
+func TestNew_NilContext(t *testing.T) {
+ t.Parallel()
+
+ //nolint:staticcheck // intentionally passing nil ctx
+ asserter := New(nil, nil, "comp", "op")
+ require.NotNil(t, asserter)
+ require.NotNil(t, asserter.ctx)
+}
+
+func TestNew_WithAllFields(t *testing.T) {
+ t.Parallel()
+
+ logger := &testLogger{}
+ ctx := context.Background()
+ asserter := New(ctx, logger, "comp", "op")
+ require.Equal(t, ctx, asserter.ctx)
+ require.Equal(t, logger, asserter.logger)
+ require.Equal(t, "comp", asserter.component)
+ require.Equal(t, "op", asserter.operation)
+}
+
+// --- formatKeyValueLines Tests ---
+
+func TestFormatKeyValueLines_Empty(t *testing.T) {
+ t.Parallel()
+
+ result := formatKeyValueLines(nil)
+ require.Empty(t, result)
+}
+
+func TestFormatKeyValueLines_SinglePair(t *testing.T) {
+ t.Parallel()
+
+ result := formatKeyValueLines([]any{"key", "value"})
+ require.Equal(t, " key=value", result)
+}
+
+func TestFormatKeyValueLines_MultiplePairs(t *testing.T) {
+ t.Parallel()
+
+ result := formatKeyValueLines([]any{"k1", "v1", "k2", "v2"})
+ require.Contains(t, result, "k1=v1")
+ require.Contains(t, result, "k2=v2")
+}
+
+func TestFormatKeyValueLines_OddCount(t *testing.T) {
+ t.Parallel()
+
+ result := formatKeyValueLines([]any{"k1", "v1", "orphan"})
+ require.Contains(t, result, "k1=v1")
+ require.Contains(t, result, "orphan=MISSING_VALUE")
+}
+
+// --- recordAssertionObservability Tests ---
+
+func TestRecordAssertionObservability_NoMetricsNoSpan(t *testing.T) {
+ // Not parallel - modifies global state.
+ ResetAssertionMetrics()
+ defer ResetAssertionMetrics()
+
+ recordAssertionObservability(context.Background(), "That", "test", nil, "comp", "op")
+}
+
+// --- isNil Tests ---
+
+func TestIsNil_UntypedNil(t *testing.T) {
+ t.Parallel()
+ require.True(t, isNil(nil))
+}
+
+func TestIsNil_TypedNilPointer(t *testing.T) {
+ t.Parallel()
+
+ var p *int
+ require.True(t, isNil(p), "typed nil pointer should be nil")
+}
+
+func TestIsNil_NonNilInt(t *testing.T) {
+ t.Parallel()
+ require.False(t, isNil(42))
+}
+
+func TestIsNil_NonNilString(t *testing.T) {
+ t.Parallel()
+ require.False(t, isNil("hello"))
+}
+
+func TestIsNil_NonNilStruct(t *testing.T) {
+ t.Parallel()
+
+ type s struct{}
+ require.False(t, isNil(s{}))
+}
+
+// --- shouldIncludeStack Tests ---
+
+func TestShouldIncludeStack_NonProduction(t *testing.T) {
+ // Not parallel - uses t.Setenv and depends on runtime global state.
+ t.Setenv("ENV", "development")
+ t.Setenv("GO_ENV", "")
+
+ require.True(t, shouldIncludeStack())
+}
+
+func TestShouldIncludeStack_ProductionENV(t *testing.T) {
+ // Not parallel - uses t.Setenv and depends on runtime global state.
+ t.Setenv("ENV", "production")
+ t.Setenv("GO_ENV", "")
+
+ require.False(t, shouldIncludeStack())
+}
+
+func TestShouldIncludeStack_ProductionGOENV(t *testing.T) {
+ // Not parallel - uses t.Setenv and depends on runtime global state.
+ t.Setenv("ENV", "")
+ t.Setenv("GO_ENV", "production")
+
+ require.False(t, shouldIncludeStack())
+}
+
+func TestShouldIncludeStack_ProductionCaseInsensitive(t *testing.T) {
+ // Not parallel - uses t.Setenv and depends on runtime global state.
+ t.Setenv("ENV", "Production")
+ t.Setenv("GO_ENV", "")
+
+ require.False(t, shouldIncludeStack())
+}
+
+func TestShouldIncludeStack_RuntimeProductionMode(t *testing.T) {
+ // Not parallel - modifies global state.
+ t.Setenv("ENV", "")
+ t.Setenv("GO_ENV", "")
+
+ libRuntime.SetProductionMode(true)
+ defer libRuntime.SetProductionMode(false)
+
+ require.False(t, shouldIncludeStack(), "should suppress stacks when runtime.IsProductionMode() is true")
+}
+
+func TestShouldIncludeStack_RuntimeProductionModeOverridesEnv(t *testing.T) {
+ // Not parallel - modifies global state.
+ t.Setenv("ENV", "development")
+ t.Setenv("GO_ENV", "development")
+
+ libRuntime.SetProductionMode(true)
+ defer libRuntime.SetProductionMode(false)
+
+ require.False(t, shouldIncludeStack(), "runtime production mode should override env vars")
+}
+
+func TestShouldIncludeStack_EnvFallbackWhenRuntimeNotSet(t *testing.T) {
+ // Not parallel - modifies global state.
+ libRuntime.SetProductionMode(false)
+ defer libRuntime.SetProductionMode(false)
+
+ t.Setenv("ENV", "production")
+ t.Setenv("GO_ENV", "")
+
+ require.False(t, shouldIncludeStack(), "env var fallback should still detect production")
+}
+
+func TestShouldIncludeStack_NonProductionWhenBothDisabled(t *testing.T) {
+ // Not parallel - modifies global state.
+ libRuntime.SetProductionMode(false)
+ defer libRuntime.SetProductionMode(false)
+
+ t.Setenv("ENV", "development")
+ t.Setenv("GO_ENV", "")
+
+ require.True(t, shouldIncludeStack(), "should include stacks in non-production mode")
+}
+
+// --- withContextPairs Tests ---
+
+func TestWithContextPairs_AllFields(t *testing.T) {
+ t.Parallel()
+
+ result := withContextPairs("That", "comp", "op", []any{"k1", "v1"})
+ require.Len(t, result, 8)
+}
+
+func TestWithContextPairs_EmptyComponent(t *testing.T) {
+ t.Parallel()
+
+ result := withContextPairs("NotNil", "", "op", nil)
+ require.Len(t, result, 4)
+}
+
+func TestWithContextPairs_EmptyOperation(t *testing.T) {
+ t.Parallel()
+
+ result := withContextPairs("NotNil", "comp", "", nil)
+ require.Len(t, result, 4)
+}
+
+func TestWithContextPairs_BothEmpty(t *testing.T) {
+ t.Parallel()
+
+ result := withContextPairs("Never", "", "", nil)
+ require.Len(t, result, 2)
+}
diff --git a/assert/assert_test.go b/assert/assert_test.go
new file mode 100644
index 0000000..f6af13c
--- /dev/null
+++ b/assert/assert_test.go
@@ -0,0 +1,591 @@
+//go:build unit
+
+package assert
+
+import (
+ "context"
+ "errors"
+ "math"
+ "testing"
+
+ "github.com/LerianStudio/lib-observability/log"
+ "github.com/shopspring/decimal"
+ "github.com/stretchr/testify/require"
+)
+
+// errTest is a test error for assertions.
+var errTest = errors.New("test error")
+
+// errSpecificTest is a specific test error for assertions.
+var errSpecificTest = errors.New("specific test error")
+
+type testLogger struct {
+ messages []string
+}
+
+func (l *testLogger) Log(_ context.Context, _ log.Level, msg string, _ ...log.Field) {
+ l.messages = append(l.messages, msg)
+}
+
+func newTestAsserter(logger Logger) *Asserter {
+ return New(context.Background(), logger, "test-component", "test-operation")
+}
+
+func newTestAsserterWithLogger() (*Asserter, *testLogger) {
+ logger := &testLogger{}
+ return newTestAsserter(logger), logger
+}
+
+// TestThat_Pass verifies That returns nil when condition is true.
+func TestThat_Pass(t *testing.T) {
+ t.Parallel()
+
+ a := newTestAsserter(nil)
+ require.NoError(t, a.That(context.Background(), true, "should not fail"))
+}
+
+// TestThat_Fail verifies That returns an error when condition is false.
+func TestThat_Fail(t *testing.T) {
+ t.Parallel()
+
+ a, _ := newTestAsserterWithLogger()
+ err := a.That(context.Background(), false, "should fail")
+ require.Error(t, err)
+ require.ErrorIs(t, err, ErrAssertionFailed)
+}
+
+// TestThat_ErrorMessage verifies the error message contains the expected content.
+func TestThat_ErrorMessage(t *testing.T) {
+ t.Parallel()
+
+ a, _ := newTestAsserterWithLogger()
+ err := a.That(context.Background(), false, "test message", "key1", "value1", "key2", 42)
+ require.Error(t, err)
+ msg := err.Error()
+ require.Contains(t, msg, "assertion failed:")
+ require.Contains(t, msg, "test message")
+ require.Contains(t, msg, "assertion=That")
+ require.Contains(t, msg, "key1=value1")
+ require.Contains(t, msg, "key2=42")
+}
+
+// TestThat_LogIncludesStackTrace verifies stack trace is logged in non-production.
+func TestThat_LogIncludesStackTrace(t *testing.T) {
+ t.Setenv("ENV", "")
+ t.Setenv("GO_ENV", "")
+
+ a, logger := newTestAsserterWithLogger()
+ err := a.That(context.Background(), false, "test message", "key1", "value1")
+ require.Error(t, err)
+ require.NotEmpty(t, logger.messages)
+ require.Contains(t, logger.messages[0], "ASSERTION FAILED")
+ require.Len(t, logger.messages, 2, "should have assertion failure + stack trace logs")
+ require.Contains(t, logger.messages[1], "assertion stack trace")
+}
+
+// TestNotNil_Pass verifies NotNil returns nil for non-nil values.
+func TestNotNil_Pass(t *testing.T) {
+ t.Parallel()
+
+ asserter := newTestAsserter(nil)
+ require.NoError(t, asserter.NotNil(context.Background(), "hello", "string should not be nil"))
+ require.NoError(t, asserter.NotNil(context.Background(), 42, "int should not be nil"))
+
+ x := new(int)
+ require.NoError(t, asserter.NotNil(context.Background(), x, "pointer should not be nil"))
+
+ s := []int{1, 2, 3}
+ require.NoError(t, asserter.NotNil(context.Background(), s, "slice should not be nil"))
+
+ m := map[string]int{"a": 1}
+ require.NoError(t, asserter.NotNil(context.Background(), m, "map should not be nil"))
+}
+
+// TestNotNil_Fail verifies NotNil returns an error for nil values.
+func TestNotNil_Fail(t *testing.T) {
+ t.Parallel()
+
+ a, _ := newTestAsserterWithLogger()
+ err := a.NotNil(context.Background(), nil, "should fail for nil")
+ require.Error(t, err)
+}
+
+// TestNotNil_TypedNil verifies NotNil correctly handles typed nil.
+func TestNotNil_TypedNil(t *testing.T) {
+ t.Parallel()
+
+ asserter, _ := newTestAsserterWithLogger()
+
+ var ptr *int
+
+ var iface any = ptr
+
+ err := asserter.NotNil(context.Background(), iface, "should fail for typed nil")
+ require.Error(t, err)
+}
+
+// TestNotNil_TypedNilSlice verifies NotNil handles typed nil slices.
+func TestNotNil_TypedNilSlice(t *testing.T) {
+ t.Parallel()
+
+ asserter, _ := newTestAsserterWithLogger()
+
+ var s []int
+
+ var iface any = s
+
+ err := asserter.NotNil(context.Background(), iface, "should fail for typed nil slice")
+ require.Error(t, err)
+}
+
+// TestNotNil_TypedNilMap verifies NotNil handles typed nil maps.
+func TestNotNil_TypedNilMap(t *testing.T) {
+ t.Parallel()
+
+ asserter, _ := newTestAsserterWithLogger()
+
+ var m map[string]int
+
+ var iface any = m
+
+ err := asserter.NotNil(context.Background(), iface, "should fail for typed nil map")
+ require.Error(t, err)
+}
+
+// TestNotNil_TypedNilChan verifies NotNil handles typed nil channels.
+func TestNotNil_TypedNilChan(t *testing.T) {
+ t.Parallel()
+
+ asserter, _ := newTestAsserterWithLogger()
+
+ var ch chan int
+
+ var iface any = ch
+
+ err := asserter.NotNil(context.Background(), iface, "should fail for typed nil channel")
+ require.Error(t, err)
+}
+
+// TestNotNil_TypedNilFunc verifies NotNil handles typed nil functions.
+func TestNotNil_TypedNilFunc(t *testing.T) {
+ t.Parallel()
+
+ asserter, _ := newTestAsserterWithLogger()
+
+ var fn func()
+
+ var iface any = fn
+
+ err := asserter.NotNil(context.Background(), iface, "should fail for typed nil function")
+ require.Error(t, err)
+}
+
+// TestNotEmpty_Pass verifies NotEmpty returns nil for non-empty strings.
+func TestNotEmpty_Pass(t *testing.T) {
+ t.Parallel()
+
+ a := newTestAsserter(nil)
+ require.NoError(t, a.NotEmpty(context.Background(), "hello", "should not fail"))
+ require.NoError(t, a.NotEmpty(context.Background(), " ", "whitespace is not empty"))
+}
+
+// TestNotEmpty_Fail verifies NotEmpty returns an error for empty strings.
+func TestNotEmpty_Fail(t *testing.T) {
+ t.Parallel()
+
+ a, _ := newTestAsserterWithLogger()
+ err := a.NotEmpty(context.Background(), "", "should fail for empty string")
+ require.Error(t, err)
+}
+
+// TestNoError_Pass verifies NoError returns nil when error is nil.
+func TestNoError_Pass(t *testing.T) {
+ t.Parallel()
+
+ a := newTestAsserter(nil)
+ require.NoError(t, a.NoError(context.Background(), nil, "should not fail"))
+}
+
+// TestNoError_Fail verifies NoError returns an error when error is not nil.
+func TestNoError_Fail(t *testing.T) {
+ t.Parallel()
+
+ a, _ := newTestAsserterWithLogger()
+ err := a.NoError(context.Background(), errTest, "should fail")
+ require.Error(t, err)
+}
+
+// TestNoError_MessageContainsError verifies the error message and type are included.
+func TestNoError_MessageContainsError(t *testing.T) {
+ t.Parallel()
+
+ a, _ := newTestAsserterWithLogger()
+ err := a.NoError(
+ context.Background(),
+ errSpecificTest,
+ "operation failed",
+ "context_key",
+ "context_value",
+ )
+ require.Error(t, err)
+ msg := err.Error()
+ require.Contains(t, msg, "assertion failed:")
+ require.Contains(t, msg, "operation failed")
+ require.Contains(t, msg, "error=specific test error")
+ require.Contains(t, msg, "error_type=")
+ require.Contains(t, msg, "context_key=context_value")
+}
+
+// TestNever_AlwaysFails verifies Never always returns an error.
+func TestNever_AlwaysFails(t *testing.T) {
+ t.Parallel()
+
+ a, _ := newTestAsserterWithLogger()
+ err := a.Never(context.Background(), "unreachable code reached")
+ require.Error(t, err)
+}
+
+// TestNever_ErrorMessage verifies Never includes message and context.
+func TestNever_ErrorMessage(t *testing.T) {
+ t.Parallel()
+
+ a, _ := newTestAsserterWithLogger()
+ err := a.Never(context.Background(), "unreachable", "state", "invalid")
+ require.Error(t, err)
+ msg := err.Error()
+ require.Contains(t, msg, "assertion failed:")
+ require.Contains(t, msg, "unreachable")
+ require.Contains(t, msg, "state=invalid")
+}
+
+// TestOddKeyValuePairs verifies handling of odd number of key-value pairs.
+func TestOddKeyValuePairs(t *testing.T) {
+ t.Parallel()
+
+ a, _ := newTestAsserterWithLogger()
+ err := a.That(context.Background(), false, "test", "key1", "value1", "key2")
+ require.Error(t, err)
+ msg := err.Error()
+ require.Contains(t, msg, "key1=value1")
+ require.Contains(t, msg, "key2=MISSING_VALUE")
+}
+
+// TestPositive tests the Positive predicate.
+func TestPositive(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ n int64
+ expected bool
+ }{
+ {"positive", 1, true},
+ {"large positive", 1000000, true},
+ {"max int64", math.MaxInt64, true},
+ {"zero", 0, false},
+ {"negative", -1, false},
+ {"large negative", -1000000, false},
+ {"min int64", math.MinInt64, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, Positive(tt.n))
+ })
+ }
+}
+
+// TestNonNegative tests the NonNegative predicate.
+func TestNonNegative(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ n int64
+ expected bool
+ }{
+ {"positive", 1, true},
+ {"max int64", math.MaxInt64, true},
+ {"zero", 0, true},
+ {"negative", -1, false},
+ {"large negative", -1000000, false},
+ {"min int64", math.MinInt64, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, NonNegative(tt.n))
+ })
+ }
+}
+
+// TestNotZero tests the NotZero predicate.
+func TestNotZero(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ n int64
+ expected bool
+ }{
+ {"positive", 1, true},
+ {"negative", -1, true},
+ {"zero", 0, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, NotZero(tt.n))
+ })
+ }
+}
+
+// TestInRange tests the InRange predicate.
+func TestInRange(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ n int64
+ min int64
+ max int64
+ expected bool
+ }{
+ {"in range", 5, 1, 10, true},
+ {"at min", 1, 1, 10, true},
+ {"at max", 10, 1, 10, true},
+ {"below range", 0, 1, 10, false},
+ {"above range", 11, 1, 10, false},
+ {"inverted range", 5, 10, 1, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, InRange(tt.n, tt.min, tt.max))
+ })
+ }
+}
+
+// TestValidUUID tests ValidUUID predicate.
+func TestValidUUID(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ uuid string
+ expected bool
+ }{
+ {"valid UUID", "123e4567-e89b-12d3-a456-426614174000", true},
+ {"valid UUID without hyphens", "123e4567e89b12d3a456426614174000", true},
+ {"empty string", "", false},
+ {"invalid format", "not-a-uuid", false},
+ {"too short", "123e4567-e89b-12d3-a456-42661417400", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, ValidUUID(tt.uuid))
+ })
+ }
+}
+
+// TestValidAmount tests ValidAmount predicate.
+func TestValidAmount(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ amount decimal.Decimal
+ expected bool
+ }{
+ {"zero", decimal.Zero, true},
+ {"max positive exponent", decimal.New(1, 18), true},
+ {"min negative exponent", decimal.New(1, -18), true},
+ {"too large exponent", decimal.New(1, 19), false},
+ {"too small exponent", decimal.New(1, -19), false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, ValidAmount(tt.amount))
+ })
+ }
+}
+
+// TestValidScale tests ValidScale predicate.
+func TestValidScale(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ scale int
+ expected bool
+ }{
+ {"min scale", 0, true},
+ {"max scale", 18, true},
+ {"negative scale", -1, false},
+ {"too large scale", 19, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, ValidScale(tt.scale))
+ })
+ }
+}
+
+// TestPositiveDecimal tests PositiveDecimal predicate.
+func TestPositiveDecimal(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ amount decimal.Decimal
+ expected bool
+ }{
+ {"positive", decimal.NewFromFloat(1.23), true},
+ {"zero", decimal.Zero, false},
+ {"negative", decimal.NewFromFloat(-1.23), false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, PositiveDecimal(tt.amount))
+ })
+ }
+}
+
+// TestNonNegativeDecimal tests NonNegativeDecimal predicate.
+func TestNonNegativeDecimal(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ amount decimal.Decimal
+ expected bool
+ }{
+ {"positive", decimal.NewFromFloat(1.23), true},
+ {"zero", decimal.Zero, true},
+ {"negative", decimal.NewFromFloat(-1.23), false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, NonNegativeDecimal(tt.amount))
+ })
+ }
+}
+
+// TestValidPort tests ValidPort predicate.
+func TestValidPort(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ port string
+ expected bool
+ }{
+ {"valid port", "5432", true},
+ {"min port", "1", true},
+ {"max port", "65535", true},
+ {"zero port", "0", false},
+ {"negative", "-1", false},
+ {"too large", "65536", false},
+ {"non-numeric", "abc", false},
+ {"empty", "", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, ValidPort(tt.port))
+ })
+ }
+}
+
+// TestValidSSLMode tests ValidSSLMode predicate.
+func TestValidSSLMode(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ mode string
+ expected bool
+ }{
+ {"empty", "", true},
+ {"disable", "disable", true},
+ {"allow", "allow", true},
+ {"prefer", "prefer", true},
+ {"require", "require", true},
+ {"verify-ca", "verify-ca", true},
+ {"verify-full", "verify-full", true},
+ {"invalid", "invalid", false},
+ {"uppercase", "DISABLE", false},
+ {"with spaces", " disable ", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, ValidSSLMode(tt.mode))
+ })
+ }
+}
+
+// TestPositiveInt tests PositiveInt predicate.
+func TestPositiveInt(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ n int
+ expected bool
+ }{
+ {"positive", 1, true},
+ {"zero", 0, false},
+ {"negative", -1, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, PositiveInt(tt.n))
+ })
+ }
+}
+
+// TestInRangeInt tests InRangeInt predicate.
+func TestInRangeInt(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ n int
+ min int
+ max int
+ expected bool
+ }{
+ {"in range", 5, 1, 10, true},
+ {"at min", 1, 1, 10, true},
+ {"at max", 10, 1, 10, true},
+ {"below range", 0, 1, 10, false},
+ {"above range", 11, 1, 10, false},
+ {"inverted range", 5, 10, 1, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, InRangeInt(tt.n, tt.min, tt.max))
+ })
+ }
+}
diff --git a/assert/doc.go b/assert/doc.go
new file mode 100644
index 0000000..bed4dd1
--- /dev/null
+++ b/assert/doc.go
@@ -0,0 +1,5 @@
+// Package assert provides context-scoped production assertions that validate domain
+// invariants at runtime without panicking. Every assertion failure returns an error,
+// records a span event, and increments a metric counter. Includes a predicates library
+// for financial domain validation alongside general-purpose checks.
+package assert
diff --git a/assert/predicates.go b/assert/predicates.go
new file mode 100644
index 0000000..a6e293c
--- /dev/null
+++ b/assert/predicates.go
@@ -0,0 +1,355 @@
+package assert
+
+import (
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/shopspring/decimal"
+)
+
+// Transaction status constants used by the predicates.
+// These mirror the domain constants from lib-commons and are kept here to avoid
+// importing non-observability packages.
+const (
+ txnCreated = "CREATED"
+ txnApproved = "APPROVED"
+ txnPending = "PENDING"
+ txnCanceled = "CANCELED"
+ txnNoted = "NOTED"
+)
+
+// Positive returns true if n > 0.
+//
+// Example:
+//
+// a.That(ctx, assert.Positive(count), "count must be positive", "count", count)
+func Positive(n int64) bool {
+ return n > 0
+}
+
+// NonNegative returns true if n >= 0.
+//
+// Example:
+//
+// a.That(ctx, assert.NonNegative(balance), "balance must not be negative", "balance", balance)
+func NonNegative(n int64) bool {
+ return n >= 0
+}
+
+// NotZero returns true if n != 0.
+//
+// Example:
+//
+// a.That(ctx, assert.NotZero(divisor), "divisor must not be zero", "divisor", divisor)
+func NotZero(n int64) bool {
+ return n != 0
+}
+
+// InRange returns true if min <= n <= max.
+//
+// Note: If min > max (inverted range), always returns false. This is fail-safe
+// behavior - callers should ensure min <= max for correct results.
+//
+// Example:
+//
+// a.That(ctx, assert.InRange(page, 1, 1000), "page out of range", "page", page)
+func InRange(n, minVal, maxVal int64) bool {
+ return n >= minVal && n <= maxVal
+}
+
+// ValidUUID returns true if s is a valid UUID string.
+//
+// Note: Accepts both canonical (with hyphens) and non-canonical (without hyphens)
+// UUID formats per RFC 4122. Empty strings return false.
+//
+// Example:
+//
+// a.That(ctx, assert.ValidUUID(id), "invalid UUID format", "id", id)
+func ValidUUID(s string) bool {
+ if s == "" {
+ return false
+ }
+
+ _, err := uuid.Parse(s)
+
+ return err == nil
+}
+
+// ValidAmount returns true if the decimal's exponent is within reasonable bounds.
+// The exponent must be in the range [-18, 18] to align with supported precision
+// for financial calculations (scale up to 18 decimal places).
+//
+// Note: This validates exponent bounds only, not coefficient size. For user-facing
+// validation, consider additional bounds checks on the coefficient.
+//
+// Example:
+//
+// a.That(ctx, assert.ValidAmount(amount), "amount has invalid precision", "amount", amount)
+func ValidAmount(amount decimal.Decimal) bool {
+ exp := amount.Exponent()
+ return exp >= -18 && exp <= 18
+}
+
+// ValidScale returns true if scale is in the range [0, 18].
+// Scale represents the number of decimal places for financial amounts.
+//
+// Example:
+//
+// a.That(ctx, assert.ValidScale(scale), "invalid scale", "scale", scale)
+func ValidScale(scale int) bool {
+ return scale >= 0 && scale <= 18
+}
+
+// PositiveDecimal returns true if amount > 0.
+//
+// Example:
+//
+// a.That(ctx, assert.PositiveDecimal(price), "price must be positive", "price", price)
+func PositiveDecimal(amount decimal.Decimal) bool {
+ return amount.IsPositive()
+}
+
+// NonNegativeDecimal returns true if amount >= 0.
+//
+// Example:
+//
+// a.That(ctx, assert.NonNegativeDecimal(balance), "balance must not be negative", "balance", balance)
+func NonNegativeDecimal(amount decimal.Decimal) bool {
+ return !amount.IsNegative()
+}
+
+// ValidPort returns true if port is a valid network port number (1-65535).
+// The port must be a numeric string representing a value in the valid range.
+//
+// Note: Port 0 is invalid for configuration purposes (it's used for dynamic allocation).
+// Empty strings, non-numeric values, and out-of-range values return false.
+//
+// Example:
+//
+// a.That(ctx, assert.ValidPort(cfg.DBPort), "DB_PORT must be valid port", "port", cfg.DBPort)
+func ValidPort(port string) bool {
+ if port == "" {
+ return false
+ }
+
+ p, err := strconv.Atoi(port)
+ if err != nil {
+ return false
+ }
+
+ return p > 0 && p <= 65535
+}
+
+// validSSLModes contains the valid PostgreSQL SSL modes.
+// Package-level for zero-allocation lookups in ValidSSLMode.
+var validSSLModes = map[string]bool{
+ "": true, // Empty uses PostgreSQL default
+ "disable": true,
+ "allow": true,
+ "prefer": true,
+ "require": true,
+ "verify-ca": true,
+ "verify-full": true,
+}
+
+// ValidSSLMode returns true if mode is a valid PostgreSQL SSL mode.
+// Valid modes are: disable, allow, prefer, require, verify-ca, verify-full.
+// Empty string is also valid (uses PostgreSQL default).
+//
+// Note: SSL modes are case-sensitive per PostgreSQL documentation.
+// Unknown modes will cause connection failures.
+//
+// Example:
+//
+// a.That(ctx, assert.ValidSSLMode(cfg.DBSSLMode), "DB_SSLMODE invalid", "mode", cfg.DBSSLMode)
+func ValidSSLMode(mode string) bool {
+ return validSSLModes[mode]
+}
+
+// PositiveInt returns true if n > 0.
+// This is the int variant of Positive (which uses int64).
+//
+// Example:
+//
+// a.That(ctx, assert.PositiveInt(cfg.MaxWorkers), "MAX_WORKERS must be positive", "value", cfg.MaxWorkers)
+func PositiveInt(n int) bool {
+ return n > 0
+}
+
+// InRangeInt returns true if min <= n <= max.
+// This is the int variant of InRange (which uses int64).
+//
+// Note: If min > max (inverted range), always returns false. This is fail-safe
+// behavior - callers should ensure min <= max for correct results.
+//
+// Example:
+//
+// a.That(ctx, assert.InRangeInt(cfg.PoolSize, 1, 100), "POOL_SIZE out of range", "value", cfg.PoolSize)
+func InRangeInt(n, minVal, maxVal int) bool {
+ return n >= minVal && n <= maxVal
+}
+
+// DebitsEqualCredits returns true if debits and credits are exactly equal.
+// This validates the fundamental double-entry accounting invariant:
+// for every transaction, total debits MUST equal total credits.
+//
+// Note: Uses decimal.Equal() for exact comparison without floating point issues.
+// Even a tiny difference indicates a bug in amount calculation.
+//
+// Example:
+//
+// a.That(ctx, assert.DebitsEqualCredits(debitTotal, creditTotal),
+// "double-entry violation: debits must equal credits",
+// "debits", debitTotal, "credits", creditTotal)
+func DebitsEqualCredits(debits, credits decimal.Decimal) bool {
+ return debits.Equal(credits)
+}
+
+// NonZeroTotals returns true if both debits and credits are non-zero.
+// A transaction with zero totals is meaningless and indicates a bug.
+//
+// Example:
+//
+// a.That(ctx, assert.NonZeroTotals(debitTotal, creditTotal),
+// "transaction totals must be non-zero",
+// "debits", debitTotal, "credits", creditTotal)
+func NonZeroTotals(debits, credits decimal.Decimal) bool {
+ return !debits.IsZero() && !credits.IsZero()
+}
+
+// validTransactionStatuses contains valid transaction status values.
+// Package-level for zero-allocation lookups.
+var validTransactionStatuses = map[string]bool{
+ txnCreated: true,
+ txnApproved: true,
+ txnPending: true,
+ txnCanceled: true,
+ txnNoted: true,
+}
+
+// ValidTransactionStatus returns true if status is a valid transaction status.
+// Valid statuses are: CREATED, APPROVED, PENDING, CANCELED, NOTED.
+//
+// Note: Statuses are case-sensitive and must match exactly.
+//
+// Example:
+//
+// a.That(ctx, assert.ValidTransactionStatus(tran.Status.Code),
+// "invalid transaction status",
+// "status", tran.Status.Code)
+func ValidTransactionStatus(status string) bool {
+ return validTransactionStatuses[status]
+}
+
+// validTransitions defines the allowed state machine transitions.
+// Key: current state, Value: set of valid target states.
+// Only PENDING transactions can be committed (APPROVED) or canceled (CANCELED).
+var validTransitions = map[string]map[string]bool{
+ txnPending: {
+ txnApproved: true,
+ txnCanceled: true,
+ },
+ // CREATED, APPROVED, CANCELED, NOTED are terminal states - no forward transitions
+}
+
+// TransactionCanTransitionTo returns true if transitioning from current to target is valid.
+// The transaction state machine only allows: PENDING -> APPROVED or PENDING -> CANCELED.
+//
+// Note: This is for forward transitions only. Revert is a separate operation.
+//
+// Example:
+//
+// a.That(ctx, assert.TransactionCanTransitionTo(current, next),
+// "invalid status transition",
+// "current", current,
+// "next", next)
+func TransactionCanTransitionTo(current, target string) bool {
+ validTargets, exists := validTransitions[current]
+ if !exists {
+ return false
+ }
+
+ return validTargets[target]
+}
+
+// TransactionCanBeReverted returns true if a transaction can be reverted.
+// The transaction can only be reverted if:
+// - Status is APPROVED
+// - It has no parent transaction (i.e., it is not a reversal of another transaction)
+//
+// This ensures only original transactions can be reverted, not reversals.
+func TransactionCanBeReverted(status string, hasParent bool) bool {
+ if status != txnApproved {
+ return false
+ }
+
+ return !hasParent
+}
+
+// BalanceSufficientForRelease returns true if the available on-hold balance
+// is sufficient to release the specified amount.
+func BalanceSufficientForRelease(onHold, releaseAmount decimal.Decimal) bool {
+ if onHold.IsNegative() || releaseAmount.IsNegative() {
+ return false
+ }
+
+ return onHold.GreaterThanOrEqual(releaseAmount)
+}
+
+// DateNotInFuture returns true if the date is not in the future (i.e., <= now).
+// Zero time is considered valid (returns true).
+func DateNotInFuture(date time.Time) bool {
+ if date.IsZero() {
+ return true
+ }
+
+ return !date.After(time.Now().UTC())
+}
+
+// DateAfter returns true if date is strictly after reference time.
+func DateAfter(date, reference time.Time) bool {
+ return date.After(reference)
+}
+
+// BalanceIsZero returns true if both available and onHold balances are exactly zero.
+func BalanceIsZero(available, onHold decimal.Decimal) bool {
+ return available.IsZero() && onHold.IsZero()
+}
+
+// TransactionHasOperations returns true if the transaction has operations.
+func TransactionHasOperations(operations []string) bool {
+ return len(operations) > 0
+}
+
+// TransactionOperationsContain returns true if every element in operations is
+// contained in the allowed set (i.e. operations is a subset of allowed).
+// Both empty operations and empty allowed return false.
+func TransactionOperationsContain(operations, allowed []string) bool {
+ if len(operations) == 0 || len(allowed) == 0 {
+ return false
+ }
+
+ allowedSet := make(map[string]struct{}, len(allowed))
+ for _, op := range allowed {
+ allowedSet[strings.TrimSpace(op)] = struct{}{}
+ }
+
+ for _, op := range operations {
+ if _, ok := allowedSet[strings.TrimSpace(op)]; !ok {
+ return false
+ }
+ }
+
+ return true
+}
+
+// TransactionOperationsMatch is a deprecated alias for TransactionOperationsContain.
+// It checks subset containment: every operation must be in the allowed set.
+//
+// Deprecated: Use TransactionOperationsContain instead. The name "Match" implied
+// full bidirectional equality, but the behavior is subset containment.
+func TransactionOperationsMatch(operations, allowed []string) bool {
+ return TransactionOperationsContain(operations, allowed)
+}
diff --git a/assert/predicates_test.go b/assert/predicates_test.go
new file mode 100644
index 0000000..175001e
--- /dev/null
+++ b/assert/predicates_test.go
@@ -0,0 +1,334 @@
+//go:build unit
+
+package assert
+
+import (
+ "testing"
+ "time"
+
+ "github.com/shopspring/decimal"
+ "github.com/stretchr/testify/require"
+)
+
+// TestDebitsEqualCredits tests the DebitsEqualCredits predicate for double-entry accounting.
+func TestDebitsEqualCredits(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ debits decimal.Decimal
+ credits decimal.Decimal
+ expected bool
+ }{
+ {"equal positive amounts", decimal.NewFromInt(100), decimal.NewFromInt(100), true},
+ {"equal with decimals", decimal.NewFromFloat(123.45), decimal.NewFromFloat(123.45), true},
+ {"equal zero", decimal.Zero, decimal.Zero, true},
+ {"debits greater", decimal.NewFromInt(100), decimal.NewFromInt(99), false},
+ {"credits greater", decimal.NewFromInt(99), decimal.NewFromInt(100), false},
+ {"tiny difference", decimal.NewFromFloat(100.001), decimal.NewFromFloat(100.002), false},
+ {"large equal", decimal.NewFromInt(1000000000), decimal.NewFromInt(1000000000), true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, DebitsEqualCredits(tt.debits, tt.credits))
+ })
+ }
+}
+
+// TestNonZeroTotals tests the NonZeroTotals predicate for transaction validation.
+func TestNonZeroTotals(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ debits decimal.Decimal
+ credits decimal.Decimal
+ expected bool
+ }{
+ {"both positive", decimal.NewFromInt(100), decimal.NewFromInt(100), true},
+ {"both zero", decimal.Zero, decimal.Zero, false},
+ {"debits zero", decimal.Zero, decimal.NewFromInt(100), false},
+ {"credits zero", decimal.NewFromInt(100), decimal.Zero, false},
+ {"small positive", decimal.NewFromFloat(0.01), decimal.NewFromFloat(0.01), true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, NonZeroTotals(tt.debits, tt.credits))
+ })
+ }
+}
+
+// TestValidTransactionStatus tests the ValidTransactionStatus predicate.
+func TestValidTransactionStatus(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ status string
+ expected bool
+ }{
+ {"CREATED valid", "CREATED", true},
+ {"APPROVED valid", "APPROVED", true},
+ {"PENDING valid", "PENDING", true},
+ {"CANCELED valid", "CANCELED", true},
+ {"NOTED valid", "NOTED", true},
+ {"empty invalid", "", false},
+ {"lowercase invalid", "pending", false},
+ {"unknown invalid", "UNKNOWN", false},
+ {"partial invalid", "APPROV", false},
+ {"with spaces invalid", " PENDING ", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, ValidTransactionStatus(tt.status))
+ })
+ }
+}
+
+// TestTransactionCanTransitionTo tests the TransactionCanTransitionTo predicate.
+func TestTransactionCanTransitionTo(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ current string
+ target string
+ expected bool
+ }{
+ {"PENDING to APPROVED", "PENDING", "APPROVED", true},
+ {"PENDING to CANCELED", "PENDING", "CANCELED", true},
+ {"PENDING to CREATED", "PENDING", "CREATED", false},
+ {"PENDING to PENDING", "PENDING", "PENDING", false},
+ {"APPROVED to CANCELED", "APPROVED", "CANCELED", false},
+ {"APPROVED to PENDING", "APPROVED", "PENDING", false},
+ {"APPROVED to CREATED", "APPROVED", "CREATED", false},
+ {"CANCELED to APPROVED", "CANCELED", "APPROVED", false},
+ {"CANCELED to PENDING", "CANCELED", "PENDING", false},
+ {"CREATED to APPROVED", "CREATED", "APPROVED", false},
+ {"CREATED to CANCELED", "CREATED", "CANCELED", false},
+ {"invalid current", "INVALID", "APPROVED", false},
+ {"invalid target", "PENDING", "INVALID", false},
+ {"both invalid", "INVALID", "UNKNOWN", false},
+ {"empty current", "", "APPROVED", false},
+ {"empty target", "PENDING", "", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, TransactionCanTransitionTo(tt.current, tt.target))
+ })
+ }
+}
+
+// TestTransactionCanBeReverted tests the TransactionCanBeReverted predicate.
+func TestTransactionCanBeReverted(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ status string
+ hasParent bool
+ expected bool
+ }{
+ {"APPROVED without parent can revert", "APPROVED", false, true},
+ {"APPROVED with parent cannot revert", "APPROVED", true, false},
+ {"PENDING cannot revert", "PENDING", false, false},
+ {"CANCELED cannot revert", "CANCELED", false, false},
+ {"CREATED cannot revert", "CREATED", false, false},
+ {"NOTED cannot revert", "NOTED", false, false},
+ {"invalid status cannot revert", "INVALID", false, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, TransactionCanBeReverted(tt.status, tt.hasParent))
+ })
+ }
+}
+
+// TestBalanceSufficientForRelease tests the BalanceSufficientForRelease predicate.
+func TestBalanceSufficientForRelease(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ onHold decimal.Decimal
+ releaseAmount decimal.Decimal
+ expected bool
+ }{
+ {"sufficient onHold", decimal.NewFromInt(100), decimal.NewFromInt(50), true},
+ {"exactly sufficient", decimal.NewFromInt(100), decimal.NewFromInt(100), true},
+ {"insufficient onHold", decimal.NewFromInt(50), decimal.NewFromInt(100), false},
+ {"zero onHold zero release", decimal.Zero, decimal.Zero, true},
+ {"zero onHold positive release", decimal.Zero, decimal.NewFromInt(1), false},
+ {
+ "decimal precision sufficient",
+ decimal.NewFromFloat(100.50),
+ decimal.NewFromFloat(100.49),
+ true,
+ },
+ {
+ "decimal precision insufficient",
+ decimal.NewFromFloat(100.49),
+ decimal.NewFromFloat(100.50),
+ false,
+ },
+ {"negative onHold always fails", decimal.NewFromInt(-10), decimal.NewFromInt(5), false},
+ {"negative releaseAmount always fails", decimal.NewFromInt(100), decimal.NewFromInt(-5), false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, BalanceSufficientForRelease(tt.onHold, tt.releaseAmount))
+ })
+ }
+}
+
+// TestDateNotInFuture tests the DateNotInFuture predicate.
+func TestDateNotInFuture(t *testing.T) {
+ t.Parallel()
+
+ now := time.Now()
+
+ tests := []struct {
+ name string
+ date time.Time
+ expected bool
+ }{
+ {"past date valid", now.Add(-24 * time.Hour), true},
+ {"recent past valid", now.Add(-time.Second), true},
+ {"one minute ago valid", now.Add(-time.Minute), true},
+ {"one minute future invalid", now.Add(time.Minute), false},
+ {"one hour future invalid", now.Add(time.Hour), false},
+ {"far future invalid", now.Add(365 * 24 * time.Hour), false},
+ {"zero time valid", time.Time{}, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ result := DateNotInFuture(tt.date)
+ require.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+// TestDateAfter tests the DateAfter predicate.
+func TestDateAfter(t *testing.T) {
+ t.Parallel()
+
+ base := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)
+
+ tests := []struct {
+ name string
+ date time.Time
+ reference time.Time
+ expected bool
+ }{
+ {"date after reference", base.Add(24 * time.Hour), base, true},
+ {"date equal to reference", base, base, false},
+ {"date before reference", base.Add(-24 * time.Hour), base, false},
+ {"date one second after", base.Add(time.Second), base, true},
+ {"date one second before", base.Add(-time.Second), base, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, DateAfter(tt.date, tt.reference))
+ })
+ }
+}
+
+// TestBalanceIsZero tests the BalanceIsZero predicate.
+func TestBalanceIsZero(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ available decimal.Decimal
+ onHold decimal.Decimal
+ expected bool
+ }{
+ {"both zero", decimal.Zero, decimal.Zero, true},
+ {"available non-zero", decimal.NewFromInt(1), decimal.Zero, false},
+ {"onHold non-zero", decimal.Zero, decimal.NewFromInt(1), false},
+ {"both non-zero", decimal.NewFromInt(1), decimal.NewFromInt(1), false},
+ {"tiny available", decimal.NewFromFloat(0.001), decimal.Zero, false},
+ {"negative available still not zero", decimal.NewFromInt(-1), decimal.Zero, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, BalanceIsZero(tt.available, tt.onHold))
+ })
+ }
+}
+
+// TestTransactionHasOperations tests the TransactionHasOperations predicate.
+func TestTransactionHasOperations(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ ops []string
+ expectedOK bool
+ }{
+ {"has operations", []string{"CREDIT"}, true},
+ {"empty operations", nil, false},
+ {"empty slice", []string{}, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expectedOK, TransactionHasOperations(tt.ops))
+ })
+ }
+}
+
+// TestTransactionOperationsContain tests the TransactionOperationsContain predicate.
+func TestTransactionOperationsContain(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ ops []string
+ allowed []string
+ expected bool
+ }{
+ {"match single", []string{"CREDIT"}, []string{"CREDIT", "DEBIT"}, true},
+ {"match multiple", []string{"CREDIT", "DEBIT"}, []string{"CREDIT", "DEBIT"}, true},
+ {"mismatch", []string{"TRANSFER"}, []string{"CREDIT", "DEBIT"}, false},
+ {"empty operations", []string{}, []string{"CREDIT"}, false},
+ {"empty allowed", []string{"CREDIT"}, []string{}, false},
+ {"whitespace tolerant", []string{" CREDIT "}, []string{"CREDIT"}, true},
+ {"whitespace mismatch", []string{" CREDIT "}, []string{"DEBIT"}, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.expected, TransactionOperationsContain(tt.ops, tt.allowed))
+ })
+ }
+}
+
+// TestTransactionOperationsMatch_DeprecatedAlias verifies the deprecated alias delegates correctly.
+func TestTransactionOperationsMatch_DeprecatedAlias(t *testing.T) {
+ t.Parallel()
+
+ require.True(t, TransactionOperationsMatch([]string{"CREDIT"}, []string{"CREDIT", "DEBIT"}))
+ require.False(t, TransactionOperationsMatch([]string{"TRANSFER"}, []string{"CREDIT", "DEBIT"}))
+}
diff --git a/constants/doc.go b/constants/doc.go
new file mode 100644
index 0000000..b30fdab
--- /dev/null
+++ b/constants/doc.go
@@ -0,0 +1,4 @@
+// Package constants provides shared observability constants including OTEL attribute
+// prefixes, metric names, event names, header constants, label sanitization utilities,
+// and sensitive field detection for cross-cutting redaction.
+package constants
diff --git a/constants/headers.go b/constants/headers.go
new file mode 100644
index 0000000..7835ed0
--- /dev/null
+++ b/constants/headers.go
@@ -0,0 +1,10 @@
+package constants
+
+const (
+ // HeaderTraceparent is the W3C traceparent header key.
+ HeaderTraceparent = "traceparent"
+ // HeaderTraceparentPascal is the PascalCase variant of the Traceparent header for gRPC metadata.
+ HeaderTraceparentPascal = "Traceparent"
+ // HeaderTracestatePascal is the PascalCase variant of the Tracestate header for gRPC metadata.
+ HeaderTracestatePascal = "Tracestate"
+)
diff --git a/constants/log.go b/constants/log.go
new file mode 100644
index 0000000..46022a4
--- /dev/null
+++ b/constants/log.go
@@ -0,0 +1,4 @@
+package constants
+
+// LoggerDefaultSeparator is the default delimiter used in composed log messages.
+const LoggerDefaultSeparator = " | "
diff --git a/constants/metadata.go b/constants/metadata.go
new file mode 100644
index 0000000..60e1f04
--- /dev/null
+++ b/constants/metadata.go
@@ -0,0 +1,8 @@
+package constants
+
+const (
+ // MetadataTraceparent is the metadata key for W3C traceparent.
+ MetadataTraceparent = "traceparent"
+ // MetadataTracestate is the metadata key for W3C tracestate.
+ MetadataTracestate = "tracestate"
+)
diff --git a/constants/obfuscation.go b/constants/obfuscation.go
new file mode 100644
index 0000000..8ed00fa
--- /dev/null
+++ b/constants/obfuscation.go
@@ -0,0 +1,6 @@
+package constants
+
+const (
+ // ObfuscatedValue is the default value used to replace sensitive fields
+ ObfuscatedValue = "********"
+)
diff --git a/constants/opentelemetry.go b/constants/opentelemetry.go
new file mode 100644
index 0000000..3c33d3c
--- /dev/null
+++ b/constants/opentelemetry.go
@@ -0,0 +1,73 @@
+package constants
+
+// TelemetrySDKName identifies this library in OTEL telemetry resource attributes.
+const TelemetrySDKName = "lib-observability/tracing"
+
+// MaxMetricLabelLength is the maximum length for metric labels to prevent cardinality explosion.
+// Used by assert, runtime, and circuitbreaker packages for label sanitization.
+const MaxMetricLabelLength = 64
+
+// Telemetry attribute key prefixes.
+const (
+ // AttrPrefixAppRequest is the prefix for application request attributes.
+ AttrPrefixAppRequest = "app.request."
+ // AttrPrefixAssertion is the prefix for assertion event attributes.
+ AttrPrefixAssertion = "assertion."
+ // AttrPrefixPanic is the prefix for panic event attributes.
+ AttrPrefixPanic = "panic."
+)
+
+// Telemetry attribute keys for database connectors.
+const (
+ // AttrDBSystem is the OTEL semantic convention attribute key for the database system name.
+ AttrDBSystem = "db.system"
+ // AttrDBName is the OTEL semantic convention attribute key for the database name.
+ AttrDBName = "db.name"
+ // AttrDBMongoDBCollection is the OTEL semantic convention attribute key for the MongoDB collection.
+ AttrDBMongoDBCollection = "db.mongodb.collection"
+)
+
+// Database system identifiers used as values for AttrDBSystem.
+const (
+ // DBSystemPostgreSQL is the OTEL semantic convention value for PostgreSQL.
+ DBSystemPostgreSQL = "postgresql"
+ // DBSystemMongoDB is the OTEL semantic convention value for MongoDB.
+ DBSystemMongoDB = "mongodb"
+ // DBSystemRedis is the OTEL semantic convention value for Redis.
+ DBSystemRedis = "redis"
+ // DBSystemRabbitMQ is the OTEL semantic convention value for RabbitMQ.
+ DBSystemRabbitMQ = "rabbitmq"
+)
+
+// Telemetry metric names.
+const (
+ // MetricPanicRecoveredTotal is the counter metric for recovered panics.
+ MetricPanicRecoveredTotal = "panic_recovered_total"
+ // MetricAssertionFailedTotal is the counter metric for failed assertions.
+ MetricAssertionFailedTotal = "assertion_failed_total"
+)
+
+// Telemetry event names.
+const (
+ // EventAssertionFailed is the span event name for assertion failures.
+ EventAssertionFailed = "assertion.failed"
+ // EventPanicRecovered is the span event name for recovered panics.
+ EventPanicRecovered = "panic.recovered"
+)
+
+// SanitizeMetricLabel truncates a label value to MaxMetricLabelLength runes
+// to prevent metric cardinality explosion in OTEL backends.
+// Truncation is rune-aware to avoid splitting multibyte UTF-8 characters.
+func SanitizeMetricLabel(value string) string {
+ if len(value) <= MaxMetricLabelLength {
+ // Fast path: if byte length is within limit, rune length is too.
+ return value
+ }
+
+ runes := []rune(value)
+ if len(runes) > MaxMetricLabelLength {
+ return string(runes[:MaxMetricLabelLength])
+ }
+
+ return value
+}
diff --git a/constants/opentelemetry_test.go b/constants/opentelemetry_test.go
new file mode 100644
index 0000000..48ddfd3
--- /dev/null
+++ b/constants/opentelemetry_test.go
@@ -0,0 +1,22 @@
+//go:build unit
+
+package constants
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSanitizeMetricLabel(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, "short", SanitizeMetricLabel("short"))
+ assert.Len(t, SanitizeMetricLabel(strings.Repeat("a", MaxMetricLabelLength+10)), MaxMetricLabelLength)
+
+ multibyte := strings.Repeat("ç", MaxMetricLabelLength+10)
+ got := SanitizeMetricLabel(multibyte)
+ assert.Equal(t, MaxMetricLabelLength, len([]rune(got)))
+ assert.Equal(t, strings.Repeat("ç", MaxMetricLabelLength), got)
+}
diff --git a/context_helpers.go b/context_helpers.go
new file mode 100644
index 0000000..c88eba8
--- /dev/null
+++ b/context_helpers.go
@@ -0,0 +1,17 @@
+package observability
+
+import "context"
+
+// ContextWithHeaderID returns a context with the given correlation/request HeaderID attached.
+// This is the ingress middleware entry-point: every inbound HTTP/gRPC request should call
+// this once so that all downstream NewTrackingFromContext calls return a stable, client-visible ID.
+func ContextWithHeaderID(ctx context.Context, headerID string) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ values := cloneContextValues(ctx)
+ values.HeaderID = headerID
+
+ return context.WithValue(ctx, ContextKey, values)
+}
diff --git a/context_tracking_test.go b/context_tracking_test.go
new file mode 100644
index 0000000..a3ec77d
--- /dev/null
+++ b/context_tracking_test.go
@@ -0,0 +1,82 @@
+//go:build unit
+
+package observability
+
+import (
+ "context"
+ "testing"
+
+ "github.com/LerianStudio/lib-observability/log"
+ "github.com/LerianStudio/lib-observability/metrics"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/attribute"
+)
+
+func TestContextTrackingComponents(t *testing.T) {
+ t.Parallel()
+
+ logger := log.NewNop()
+ tracer := otel.Tracer("test")
+ factory := metrics.NewNopFactory()
+
+ ctx := ContextWithLogger(nil, logger)
+ ctx = ContextWithTracer(ctx, tracer)
+ ctx = ContextWithMetricFactory(ctx, factory)
+ ctx = ContextWithHeaderID(ctx, " request-1 ")
+
+ gotLogger, gotTracer, gotHeaderID, gotFactory := NewTrackingFromContext(ctx)
+ assert.Same(t, logger, gotLogger)
+ assert.Equal(t, tracer, gotTracer)
+ assert.Equal(t, "request-1", gotHeaderID)
+ assert.Same(t, factory, gotFactory)
+ assert.Same(t, logger, NewLoggerFromContext(ctx))
+}
+
+func TestContextTrackingDefaults(t *testing.T) {
+ t.Parallel()
+
+ logger, tracer, headerID, factory := NewTrackingFromContext(nil)
+ require.NotNil(t, logger)
+ require.NotNil(t, tracer)
+ require.NotEmpty(t, headerID)
+ require.NotNil(t, factory)
+ assert.NotNil(t, NewLoggerFromContext(context.Background()))
+ assert.NotNil(t, resolveMetricFactory(nil))
+ assert.NotNil(t, newDefaultTrackingComponents().MetricFactory)
+}
+
+func TestContextTrackingFallbacksFromEmptyContextValue(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.WithValue(context.Background(), ContextKey, &ContextValue{
+ HeaderID: " ",
+ })
+
+ logger, tracer, headerID, factory := NewTrackingFromContext(ctx)
+ assert.NotNil(t, logger)
+ assert.NotNil(t, tracer)
+ assert.NotEmpty(t, headerID)
+ assert.NotNil(t, factory)
+}
+
+func TestContextAttributesAreCopied(t *testing.T) {
+ t.Parallel()
+
+ ctx := ContextWithSpanAttributes(context.Background(), attribute.String("tenant.id", "tenant-1"))
+ attrs := AttributesFromContext(ctx)
+ require.Len(t, attrs, 1)
+ attrs[0] = attribute.String("tenant.id", "mutated")
+
+ assert.Equal(t, "tenant-1", AttributesFromContext(ctx)[0].Value.AsString())
+
+ child := ContextWithSpanAttributes(ctx, attribute.String("region", "br"))
+ assert.Len(t, AttributesFromContext(ctx), 1)
+ assert.Len(t, AttributesFromContext(child), 2)
+
+ replaced := ReplaceAttributes(child)
+ assert.Empty(t, AttributesFromContext(replaced))
+ assert.Nil(t, AttributesFromContext(nil))
+ assert.Same(t, ctx, ContextWithSpanAttributes(ctx))
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..9e1b461
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,58 @@
+module github.com/LerianStudio/lib-observability
+
+go 1.25.10
+
+require (
+ github.com/gofiber/fiber/v2 v2.52.13
+ github.com/google/uuid v1.6.0
+ github.com/shirou/gopsutil v3.21.11+incompatible
+ github.com/shopspring/decimal v1.4.0
+ github.com/stretchr/testify v1.11.1
+ go.opentelemetry.io/contrib/bridges/otelzap v0.18.0
+ go.opentelemetry.io/otel v1.43.0
+ go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0
+ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
+ go.opentelemetry.io/otel/log v0.19.0
+ go.opentelemetry.io/otel/metric v1.43.0
+ go.opentelemetry.io/otel/sdk v1.43.0
+ go.opentelemetry.io/otel/sdk/log v0.19.0
+ go.opentelemetry.io/otel/sdk/metric v1.43.0
+ go.opentelemetry.io/otel/trace v1.43.0
+ go.uber.org/mock v0.6.0
+ go.uber.org/zap v1.28.0
+ google.golang.org/grpc v1.81.0
+)
+
+require (
+ github.com/andybalholm/brotli v1.2.0 // indirect
+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
+ github.com/klauspost/compress v1.18.5 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-runewidth v0.0.21 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/tklauser/go-sysconf v0.3.16 // indirect
+ github.com/tklauser/numcpus v0.11.0 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasthttp v1.69.0 // indirect
+ github.com/yusufpapurcu/wmi v1.2.4 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/proto/otlp v1.10.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ golang.org/x/net v0.53.0 // indirect
+ golang.org/x/sys v0.43.0 // indirect
+ golang.org/x/text v0.36.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..340e165
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,128 @@
+github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
+github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+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=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk=
+github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
+github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
+github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
+github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
+github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
+github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
+github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
+github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
+github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
+github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/bridges/otelzap v0.18.0 h1:EkWTww6Nqs2P29r01NeuNsG7qNJtoWWaT1fx/CKode8=
+go.opentelemetry.io/contrib/bridges/otelzap v0.18.0/go.mod h1:lj3bgA/c7nJy0NhxqyvWJFC30aTgB+G0RKDdLbvJ4QM=
+go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
+go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y=
+go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
+go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
+go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk=
+go.opentelemetry.io/otel/log/logtest v0.19.0 h1:HdSsl4ndTK15LtJGLWBfMsSlLrCgSeE3VMzwOrLYiYs=
+go.opentelemetry.io/otel/log/logtest v0.19.0/go.mod h1:c1sH1nOHTwfMCWhhQTdWGqxgDjZhtkbkzAqGGyj0Ijs=
+go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
+go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
+go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
+go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
+go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko=
+go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg=
+go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk=
+go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk=
+go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
+go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
+go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
+go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
+go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
+go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
+go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
+golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
+google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
+google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/log/doc.go b/log/doc.go
new file mode 100644
index 0000000..3fde801
--- /dev/null
+++ b/log/doc.go
@@ -0,0 +1,6 @@
+// Package log provides a minimal, implementation-agnostic Logger interface with typed
+// Field constructors, severity levels, and production-safe error sanitization.
+//
+// Implementations include GoLogger (stdlib-based with CWE-117 log-injection prevention)
+// and NopLogger (no-op for tests and disabled logging).
+package log
diff --git a/log/go_logger.go b/log/go_logger.go
new file mode 100644
index 0000000..78347f6
--- /dev/null
+++ b/log/go_logger.go
@@ -0,0 +1,212 @@
+package log
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "reflect"
+ "strings"
+
+ "github.com/LerianStudio/lib-observability/redaction"
+)
+
+var logControlCharReplacer = strings.NewReplacer(
+ "\n", `\n`,
+ "\r", `\r`,
+ "\t", `\t`,
+ "\x00", `\0`,
+)
+
+func sanitizeLogString(s string) string {
+ return logControlCharReplacer.Replace(s)
+}
+
+// GoLogger is the stdlib logger implementation for Logger.
+type GoLogger struct {
+ Level Level
+ fields []Field
+ groups []string
+}
+
+// Enabled reports whether the logger emits entries at the given level.
+// On a nil receiver, Enabled returns false silently. Use NopLogger as the
+// documented nil-safe alternative.
+//
+// Unknown level policy: levels outside the defined range (LevelError..LevelDebug)
+// are treated as suppressed by GoLogger (since their numeric value exceeds any
+// configured threshold). The zap adapter maps unknown levels to Info. The net
+// effect is: unknown levels produce Info-level output if a zap backend is used,
+// or are suppressed in the stdlib GoLogger. Callers should use only the defined
+// Level constants.
+func (l *GoLogger) Enabled(level Level) bool {
+ if l == nil {
+ return false
+ }
+
+ return l.Level >= level
+}
+
+// Log writes a single log line if the level is enabled.
+func (l *GoLogger) Log(_ context.Context, level Level, msg string, fields ...Field) {
+ if !l.Enabled(level) {
+ return
+ }
+
+ line := l.hydrateLine(level, msg, fields...)
+ log.Print(line)
+}
+
+// With returns a child logger with additional persistent fields.
+//
+//nolint:ireturn
+func (l *GoLogger) With(fields ...Field) Logger {
+ if l == nil {
+ return &NopLogger{}
+ }
+
+ newFields := make([]Field, 0, len(l.fields)+len(fields))
+ newFields = append(newFields, l.fields...)
+ newFields = append(newFields, fields...)
+
+ newGroups := make([]string, 0, len(l.groups))
+ newGroups = append(newGroups, l.groups...)
+
+ return &GoLogger{
+ Level: l.Level,
+ fields: newFields,
+ groups: newGroups,
+ }
+}
+
+// WithGroup returns a child logger scoped under the provided group name.
+// Empty or whitespace-only names are silently ignored, consistent with
+// the zap adapter. This avoids creating unnecessary allocations.
+//
+//nolint:ireturn
+func (l *GoLogger) WithGroup(name string) Logger {
+ if l == nil {
+ return &NopLogger{}
+ }
+
+ if strings.TrimSpace(name) == "" {
+ return l
+ }
+
+ newGroups := make([]string, 0, len(l.groups)+1)
+ newGroups = append(newGroups, l.groups...)
+ newGroups = append(newGroups, sanitizeLogString(name))
+
+ newFields := make([]Field, 0, len(l.fields))
+ newFields = append(newFields, l.fields...)
+
+ return &GoLogger{
+ Level: l.Level,
+ fields: newFields,
+ groups: newGroups,
+ }
+}
+
+// Sync flushes buffered logs. It is a no-op for the stdlib logger.
+func (l *GoLogger) Sync(_ context.Context) error { return nil }
+
+func (l *GoLogger) hydrateLine(level Level, msg string, fields ...Field) string {
+ parts := make([]string, 0, 4)
+ parts = append(parts, fmt.Sprintf("[%s]", level.String()))
+
+ if l != nil && len(l.groups) > 0 {
+ parts = append(parts, fmt.Sprintf("[group=%s]", strings.Join(l.groups, ".")))
+ }
+
+ allFields := make([]Field, 0, len(fields))
+ if l != nil {
+ allFields = append(allFields, l.fields...)
+ }
+
+ allFields = append(allFields, fields...)
+
+ if rendered := renderFields(allFields); rendered != "" {
+ parts = append(parts, rendered)
+ }
+
+ parts = append(parts, sanitizeLogString(msg))
+
+ return strings.Join(parts, " ")
+}
+
+// redactedValue is the placeholder used for sensitive field values in log output.
+const redactedValue = "[REDACTED]"
+
+func renderFields(fields []Field) string {
+ if len(fields) == 0 {
+ return ""
+ }
+
+ parts := make([]string, 0, len(fields))
+ for _, field := range fields {
+ key := sanitizeLogString(field.Key)
+ if key == "" {
+ continue
+ }
+
+ var rendered any
+ if redaction.IsSensitiveField(field.Key) {
+ rendered = redactedValue
+ } else {
+ rendered = sanitizeFieldValue(field.Value)
+ }
+
+ parts = append(parts, fmt.Sprintf("%s=%v", key, rendered))
+ }
+
+ if len(parts) == 0 {
+ return ""
+ }
+
+ return fmt.Sprintf("[%s]", strings.Join(parts, ", "))
+}
+
+// isTypedNil reports whether v is a non-nil interface wrapping a nil pointer.
+// This prevents panics when calling methods (Error, String) on typed-nil values.
+func isTypedNil(v any) bool {
+ if v == nil {
+ return false
+ }
+
+ rv := reflect.ValueOf(v)
+
+ switch rv.Kind() {
+ case reflect.Pointer, reflect.Interface, reflect.Func, reflect.Map, reflect.Slice, reflect.Chan:
+ return rv.IsNil()
+ default:
+ return false
+ }
+}
+
+func sanitizeFieldValue(value any) any {
+ if value == nil {
+ return nil
+ }
+
+ // Guard against typed-nil before calling interface methods.
+ if isTypedNil(value) {
+ return ""
+ }
+
+ switch v := value.(type) {
+ case string:
+ return sanitizeLogString(v)
+ case error:
+ return sanitizeLogString(v.Error())
+ case fmt.Stringer:
+ return sanitizeLogString(v.String())
+ case bool, int, int8, int16, int32, int64,
+ uint, uint8, uint16, uint32, uint64,
+ float32, float64:
+ // Primitive types cannot carry newlines; pass through unchanged.
+ return value
+ default:
+ // Composite types (structs, slices, maps, etc.) may carry raw newlines
+ // when rendered with fmt. Pre-serialize and sanitize the result.
+ return sanitizeLogString(fmt.Sprintf("%v", v))
+ }
+}
diff --git a/log/log.go b/log/log.go
new file mode 100644
index 0000000..1a70456
--- /dev/null
+++ b/log/log.go
@@ -0,0 +1,123 @@
+package log
+
+import (
+ "context"
+ "fmt"
+ "strings"
+)
+
+// Logger is the package interface for v2 logging.
+//
+//go:generate mockgen --destination=log_mock.go --package=log . Logger
+type Logger interface {
+ Log(ctx context.Context, level Level, msg string, fields ...Field)
+ With(fields ...Field) Logger
+ WithGroup(name string) Logger
+ Enabled(level Level) bool
+ Sync(ctx context.Context) error
+}
+
+// Level represents the severity of a log entry.
+//
+// Lower numeric values indicate higher severity (LevelError=0 is most severe,
+// LevelDebug=3 is least). This is inverted from slog/zap conventions where
+// higher numeric values mean higher severity.
+//
+// The GoLogger.Enabled comparison uses l.Level >= level, which works because
+// the logger's Level acts as a verbosity ceiling: a logger at LevelInfo (2)
+// emits Error (0), Warn (1), and Info (2) messages, but suppresses Debug (3).
+type Level uint8
+
+// Level constants define log severity. Lower numeric values indicate higher
+// severity. Setting a logger's Level to a given constant enables that level
+// and all levels with lower numeric values (i.e., higher severity).
+//
+// LevelError (0) -- only errors
+// LevelWarn (1) -- errors + warnings
+// LevelInfo (2) -- errors + warnings + info
+// LevelDebug (3) -- everything
+const (
+ levelDebugString = "debug"
+ levelInfoString = "info"
+ levelWarnString = "warn"
+ levelErrorString = "error"
+
+ errorFieldKey = "error"
+
+ LevelError Level = iota
+ LevelWarn
+ LevelInfo
+ LevelDebug
+)
+
+// LevelUnknown represents an invalid or unrecognized log level.
+// Returned by ParseLevel on error to distinguish from LevelError (the zero value).
+const LevelUnknown Level = 255
+
+// String returns the string representation of a log level.
+func (level Level) String() string {
+ switch level {
+ case LevelDebug:
+ return levelDebugString
+ case LevelInfo:
+ return levelInfoString
+ case LevelWarn:
+ return levelWarnString
+ case LevelError:
+ return levelErrorString
+ default:
+ return "unknown"
+ }
+}
+
+// ParseLevel takes a string level and returns a Level constant.
+// Leading and trailing whitespace is trimmed before matching.
+func ParseLevel(lvl string) (Level, error) {
+ switch strings.ToLower(strings.TrimSpace(lvl)) {
+ case levelDebugString:
+ return LevelDebug, nil
+ case levelInfoString:
+ return LevelInfo, nil
+ case levelWarnString, "warning":
+ return LevelWarn, nil
+ case levelErrorString:
+ return LevelError, nil
+ }
+
+ return LevelUnknown, fmt.Errorf("not a valid Level: %q", lvl)
+}
+
+// Field is a strongly-typed key/value attribute attached to a log event.
+type Field struct {
+ Key string
+ Value any
+}
+
+// Any creates a field with an arbitrary value.
+//
+// WARNING: prefer typed constructors (String, Int, Bool, Err) to avoid
+// accidentally logging sensitive data (passwords, tokens, PII). If using
+// Any, ensure the value is sanitized or non-sensitive.
+func Any(key string, value any) Field {
+ return Field{Key: key, Value: value}
+}
+
+// String creates a string field.
+func String(key, value string) Field {
+ return Field{Key: key, Value: value}
+}
+
+// Int creates an integer field.
+func Int(key string, value int) Field {
+ return Field{Key: key, Value: value}
+}
+
+// Bool creates a boolean field.
+func Bool(key string, value bool) Field {
+ return Field{Key: key, Value: value}
+}
+
+// Err creates the conventional `error` field.
+func Err(err error) Field {
+ return Field{Key: errorFieldKey, Value: err}
+}
diff --git a/log/log_mock.go b/log/log_mock.go
new file mode 100644
index 0000000..7935d85
--- /dev/null
+++ b/log/log_mock.go
@@ -0,0 +1,118 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/LerianStudio/lib-observability/log (interfaces: Logger)
+//
+// Generated by this command:
+//
+// mockgen --destination=log/log_mock.go --package=log github.com/LerianStudio/lib-observability/log Logger
+//
+
+// Package log is a generated GoMock package.
+package log
+
+import (
+ context "context"
+ reflect "reflect"
+
+ gomock "go.uber.org/mock/gomock"
+)
+
+// MockLogger is a mock of Logger interface.
+type MockLogger struct {
+ ctrl *gomock.Controller
+ recorder *MockLoggerMockRecorder
+ isgomock struct{}
+}
+
+// MockLoggerMockRecorder is the mock recorder for MockLogger.
+type MockLoggerMockRecorder struct {
+ mock *MockLogger
+}
+
+// NewMockLogger creates a new mock instance.
+func NewMockLogger(ctrl *gomock.Controller) *MockLogger {
+ mock := &MockLogger{ctrl: ctrl}
+ mock.recorder = &MockLoggerMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockLogger) EXPECT() *MockLoggerMockRecorder {
+ return m.recorder
+}
+
+// Enabled mocks base method.
+func (m *MockLogger) Enabled(level Level) bool {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Enabled", level)
+ ret0, _ := ret[0].(bool)
+ return ret0
+}
+
+// Enabled indicates an expected call of Enabled.
+func (mr *MockLoggerMockRecorder) Enabled(level any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Enabled", reflect.TypeOf((*MockLogger)(nil).Enabled), level)
+}
+
+// Log mocks base method.
+func (m *MockLogger) Log(ctx context.Context, level Level, msg string, fields ...Field) {
+ m.ctrl.T.Helper()
+ varargs := []any{ctx, level, msg}
+ for _, a := range fields {
+ varargs = append(varargs, a)
+ }
+ m.ctrl.Call(m, "Log", varargs...)
+}
+
+// Log indicates an expected call of Log.
+func (mr *MockLoggerMockRecorder) Log(ctx, level, msg any, fields ...any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]any{ctx, level, msg}, fields...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Log", reflect.TypeOf((*MockLogger)(nil).Log), varargs...)
+}
+
+// Sync mocks base method.
+func (m *MockLogger) Sync(ctx context.Context) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Sync", ctx)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Sync indicates an expected call of Sync.
+func (mr *MockLoggerMockRecorder) Sync(ctx any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Sync", reflect.TypeOf((*MockLogger)(nil).Sync), ctx)
+}
+
+// With mocks base method.
+func (m *MockLogger) With(fields ...Field) Logger {
+ m.ctrl.T.Helper()
+ varargs := []any{}
+ for _, a := range fields {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "With", varargs...)
+ ret0, _ := ret[0].(Logger)
+ return ret0
+}
+
+// With indicates an expected call of With.
+func (mr *MockLoggerMockRecorder) With(fields ...any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "With", reflect.TypeOf((*MockLogger)(nil).With), fields...)
+}
+
+// WithGroup mocks base method.
+func (m *MockLogger) WithGroup(name string) Logger {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "WithGroup", name)
+ ret0, _ := ret[0].(Logger)
+ return ret0
+}
+
+// WithGroup indicates an expected call of WithGroup.
+func (mr *MockLoggerMockRecorder) WithGroup(name any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithGroup", reflect.TypeOf((*MockLogger)(nil).WithGroup), name)
+}
diff --git a/log/log_test.go b/log/log_test.go
new file mode 100644
index 0000000..498661b
--- /dev/null
+++ b/log/log_test.go
@@ -0,0 +1,680 @@
+//go:build unit
+
+package log
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ stdlog "log"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+)
+
+var stdLoggerOutputMu sync.Mutex
+
+func withTestLoggerOutput(t *testing.T, output *bytes.Buffer) {
+ t.Helper()
+
+ stdLoggerOutputMu.Lock()
+ defer t.Cleanup(func() {
+ stdLoggerOutputMu.Unlock()
+ })
+
+ originalOutput := stdlog.Writer()
+ stdlog.SetOutput(output)
+ t.Cleanup(func() { stdlog.SetOutput(originalOutput) })
+}
+
+func TestParseLevel(t *testing.T) {
+ tests := []struct {
+ in string
+ expected Level
+ err bool
+ }{
+ {in: "error", expected: LevelError},
+ {in: "warn", expected: LevelWarn},
+ {in: "warning", expected: LevelWarn},
+ {in: "info", expected: LevelInfo},
+ {in: "debug", expected: LevelDebug},
+ {in: "panic", err: true},
+ {in: "fatal", err: true},
+ {in: "INVALID", err: true},
+ }
+
+ for _, tt := range tests {
+ level, err := ParseLevel(tt.in)
+ if tt.err {
+ assert.Error(t, err)
+ continue
+ }
+
+ assert.NoError(t, err)
+ assert.Equal(t, tt.expected, level)
+ }
+}
+
+func TestGoLogger_Enabled(t *testing.T) {
+ logger := &GoLogger{Level: LevelInfo}
+ assert.True(t, logger.Enabled(LevelError))
+ assert.True(t, logger.Enabled(LevelInfo))
+ assert.False(t, logger.Enabled(LevelDebug))
+}
+
+func TestGoLogger_LogWithFieldsAndGroup(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := (&GoLogger{Level: LevelDebug}).
+ WithGroup("http").
+ With(String("request_id", "r-1"))
+
+ logger.Log(context.Background(), LevelInfo, "request finished", Int("status", 200))
+
+ out := buf.String()
+ assert.Contains(t, out, "[info]")
+ assert.Contains(t, out, "group=http")
+ assert.Contains(t, out, "request_id=r-1")
+ assert.Contains(t, out, "status=200")
+ assert.Contains(t, out, "request finished")
+}
+
+func TestGoLogger_WithIsImmutable(t *testing.T) {
+ base := &GoLogger{Level: LevelDebug}
+ withField := base.With(String("k", "v"))
+
+ assert.NotEqual(t, base, withField)
+ assert.Empty(t, base.fields)
+
+ goLogger, ok := withField.(*GoLogger)
+ require.True(t, ok, "expected *GoLogger from With()")
+ assert.Len(t, goLogger.fields, 1)
+}
+
+func TestNopLogger(t *testing.T) {
+ nop := NewNop()
+ assert.NotPanics(t, func() {
+ nop.Log(context.Background(), LevelInfo, "hello")
+ _ = nop.With(String("k", "v"))
+ _ = nop.WithGroup("x")
+ _ = nop.Sync(context.Background())
+ })
+ assert.False(t, nop.Enabled(LevelError))
+}
+
+func TestLevelLegacyNamesRejected(t *testing.T) {
+ _, panicErr := ParseLevel("panic")
+ _, fatalErr := ParseLevel("fatal")
+ assert.Error(t, panicErr)
+ assert.Error(t, fatalErr)
+}
+
+// TestNoLegacyLevelSymbolsInAPI verifies that ParseLevel rejects legacy level
+// names ("panic", "fatal") that were removed in the v2 API migration.
+func TestNoLegacyLevelSymbolsInAPI(t *testing.T) {
+ legacyNames := []string{"panic", "fatal", "PANIC", "FATAL", "Panic", "Fatal"}
+ for _, name := range legacyNames {
+ level, err := ParseLevel(name)
+ assert.Error(t, err, "ParseLevel(%q) should reject legacy level name", name)
+ assert.Equal(t, LevelUnknown, level,
+ "ParseLevel(%q) should return LevelUnknown for rejected names", name)
+ }
+
+ // Confirm no level constant stringifies to legacy names
+ for _, lvl := range []Level{LevelError, LevelWarn, LevelInfo, LevelDebug} {
+ s := lvl.String()
+ assert.NotEqual(t, "panic", s, "no Level constant should stringify to 'panic'")
+ assert.NotEqual(t, "fatal", s, "no Level constant should stringify to 'fatal'")
+ }
+}
+
+// ===========================================================================
+// CWE-117: Log Injection Prevention Tests
+// ===========================================================================
+
+func TestCWE117_MessageNewlineInjection(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ }{
+ {
+ name: "LF newline injection",
+ input: "legitimate message\n[info] forged log entry",
+ },
+ {
+ name: "CR injection",
+ input: "legitimate message\r[info] forged log entry",
+ },
+ {
+ name: "CRLF injection",
+ input: "legitimate message\r\n[info] forged log entry",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := &GoLogger{Level: LevelDebug}
+ logger.Log(context.Background(), LevelInfo, tt.input)
+
+ out := buf.String()
+ newlineCount := strings.Count(out, "\n")
+ assert.Equal(t, 1, newlineCount,
+ "CWE-117: log output must be a single line, got %d newlines in: %q", newlineCount, out)
+
+ assert.NotContains(t, out, "\n[info] forged")
+ })
+ }
+}
+
+func TestCWE117_FieldValueInjection(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := &GoLogger{Level: LevelDebug}
+ maliciousValue := "normal_user\n[error] ADMIN ACCESS GRANTED user=admin action=delete_all"
+ logger.Log(context.Background(), LevelInfo, "user login", String("user_id", maliciousValue))
+
+ out := buf.String()
+ newlineCount := strings.Count(out, "\n")
+ assert.Equal(t, 1, newlineCount,
+ "CWE-117: field injection must not create extra log lines, got: %q", out)
+}
+
+func TestCWE117_FieldKeyInjection(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := &GoLogger{Level: LevelDebug}
+ logger.Log(context.Background(), LevelInfo, "event",
+ String("key\ninjected_key", "value"))
+
+ out := buf.String()
+ newlineCount := strings.Count(out, "\n")
+ assert.Equal(t, 1, newlineCount,
+ "CWE-117: field key injection must not create extra log lines")
+}
+
+func TestCWE117_GroupNameInjection(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := (&GoLogger{Level: LevelDebug}).
+ WithGroup("safe\n[error] forged entry")
+
+ logger.Log(context.Background(), LevelInfo, "test message")
+
+ out := buf.String()
+ newlineCount := strings.Count(out, "\n")
+ assert.Equal(t, 1, newlineCount,
+ "CWE-117: group name injection must not create extra log lines")
+}
+
+func TestCWE117_NullByteInjection(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := &GoLogger{Level: LevelDebug}
+ logger.Log(context.Background(), LevelInfo, "before\x00after")
+
+ out := buf.String()
+ assert.NotContains(t, out, "\x00",
+ "CWE-117: null bytes must not appear in log output")
+}
+
+func TestCWE117_ANSIEscapeSequences(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := &GoLogger{Level: LevelDebug}
+ logger.Log(context.Background(), LevelInfo, "normal \x1b[31mRED ALERT\x1b[0m normal")
+
+ out := buf.String()
+ newlineCount := strings.Count(out, "\n")
+ assert.Equal(t, 1, newlineCount,
+ "ANSI escapes must not break single-line log output")
+ assert.Contains(t, out, "normal")
+}
+
+func TestCWE117_TabInjection(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := &GoLogger{Level: LevelDebug}
+ logger.Log(context.Background(), LevelInfo, "field1\tfield2\tfield3")
+
+ out := buf.String()
+ assert.NotContains(t, out, "\t",
+ "tab characters should be escaped in log output")
+ assert.Contains(t, out, `\t`)
+}
+
+func TestCWE117_MultipleVectorsSimultaneously(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := &GoLogger{Level: LevelDebug}
+ attack := "msg\n[error] fake\r[warn] also fake\ttab\x00null"
+ logger.Log(context.Background(), LevelInfo, attack,
+ String("user\nfake_key", "val\nfake_val"))
+
+ out := buf.String()
+ newlineCount := strings.Count(out, "\n")
+ assert.Equal(t, 1, newlineCount,
+ "CWE-117: combined attack must not create multiple log lines")
+}
+
+func TestCWE117_VeryLongMessageDoesNotCrash(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := &GoLogger{Level: LevelDebug}
+
+ var sb strings.Builder
+ for i := 0; i < 100; i++ {
+ sb.WriteString(strings.Repeat("A", 1000))
+ sb.WriteString("\n[error] forged entry ")
+ }
+
+ longMsg := sb.String()
+
+ assert.NotPanics(t, func() {
+ logger.Log(context.Background(), LevelInfo, longMsg)
+ })
+
+ out := buf.String()
+ newlineCount := strings.Count(out, "\n")
+ assert.Equal(t, 1, newlineCount,
+ "CWE-117: very long message with injections must remain single-line")
+}
+
+// ===========================================================================
+// GoLogger Behavioral Tests
+// ===========================================================================
+
+func TestGoLogger_OutputFormat(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := &GoLogger{Level: LevelDebug}
+ logger.Log(context.Background(), LevelError, "something broke", String("code", "500"))
+
+ out := buf.String()
+ assert.Contains(t, out, "[error]")
+ assert.Contains(t, out, "code=500")
+ assert.Contains(t, out, "something broke")
+}
+
+func TestGoLogger_LevelFiltering(t *testing.T) {
+ tests := []struct {
+ name string
+ loggerLvl Level
+ msgLvl Level
+ shouldEmit bool
+ }{
+ {"error logger emits error", LevelError, LevelError, true},
+ {"error logger suppresses warn", LevelError, LevelWarn, false},
+ {"error logger suppresses info", LevelError, LevelInfo, false},
+ {"error logger suppresses debug", LevelError, LevelDebug, false},
+ {"warn logger emits error", LevelWarn, LevelError, true},
+ {"warn logger emits warn", LevelWarn, LevelWarn, true},
+ {"warn logger suppresses info", LevelWarn, LevelInfo, false},
+ {"info logger emits info", LevelInfo, LevelInfo, true},
+ {"info logger emits error", LevelInfo, LevelError, true},
+ {"debug logger emits everything", LevelDebug, LevelDebug, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := &GoLogger{Level: tt.loggerLvl}
+ logger.Log(context.Background(), tt.msgLvl, "test message")
+
+ if tt.shouldEmit {
+ assert.NotEmpty(t, buf.String(), "expected message to be emitted")
+ } else {
+ assert.Empty(t, buf.String(), "expected message to be suppressed")
+ }
+ })
+ }
+}
+
+func TestGoLogger_WithPreservesFields(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := (&GoLogger{Level: LevelDebug}).
+ With(String("service", "payments"), Int("version", 2))
+
+ logger.Log(context.Background(), LevelInfo, "started")
+
+ out := buf.String()
+ assert.Contains(t, out, "service=payments")
+ assert.Contains(t, out, "version=2")
+}
+
+func TestGoLogger_WithGroupNesting(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := (&GoLogger{Level: LevelDebug}).
+ WithGroup("http").
+ WithGroup("middleware")
+
+ logger.Log(context.Background(), LevelInfo, "applied")
+
+ out := buf.String()
+ assert.Contains(t, out, "group=http.middleware")
+}
+
+func TestGoLogger_WithGroupEmptyNameIgnored(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := (&GoLogger{Level: LevelDebug}).
+ WithGroup("").
+ WithGroup(" ")
+
+ logger.Log(context.Background(), LevelInfo, "test")
+
+ out := buf.String()
+ assert.NotContains(t, out, "group=")
+}
+
+func TestGoLogger_SyncReturnsNil(t *testing.T) {
+ logger := &GoLogger{Level: LevelInfo}
+ assert.NoError(t, logger.Sync(context.Background()))
+}
+
+func TestGoLogger_NilReceiverSafety(t *testing.T) {
+ var logger *GoLogger
+
+ assert.False(t, logger.Enabled(LevelError))
+
+ assert.NotPanics(t, func() {
+ child := logger.With(String("k", "v"))
+ require.NotNil(t, child)
+ })
+
+ assert.NotPanics(t, func() {
+ child := logger.WithGroup("grp")
+ require.NotNil(t, child)
+ })
+}
+
+func TestGoLogger_EmptyFieldKeySkipped(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := &GoLogger{Level: LevelDebug}
+ logger.Log(context.Background(), LevelInfo, "msg", String("", "should_be_dropped"))
+
+ out := buf.String()
+ assert.NotContains(t, out, "should_be_dropped")
+}
+
+func TestGoLogger_BoolAndErrFields(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := &GoLogger{Level: LevelDebug}
+ logger.Log(context.Background(), LevelInfo, "event",
+ Bool("active", true),
+ Err(assert.AnError))
+
+ out := buf.String()
+ assert.Contains(t, out, "active=true")
+ assert.Contains(t, out, "error=")
+}
+
+func TestGoLogger_AnyFieldConstructor(t *testing.T) {
+ f := Any("data", map[string]int{"count": 42})
+ assert.Equal(t, "data", f.Key)
+ assert.NotNil(t, f.Value)
+}
+
+func TestGoLogger_SensitiveFieldRedaction(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := &GoLogger{Level: LevelDebug}
+ logger.Log(context.Background(), LevelInfo, "login attempt",
+ String("password", "super_secret"),
+ String("api_key", "key-12345"),
+ String("user_id", "u-42"),
+ )
+
+ out := buf.String()
+ assert.NotContains(t, out, "super_secret", "password value must be redacted")
+ assert.NotContains(t, out, "key-12345", "api_key value must be redacted")
+ assert.Contains(t, out, "[REDACTED]", "redacted fields must show [REDACTED]")
+ assert.Contains(t, out, "user_id=u-42", "non-sensitive fields must pass through")
+}
+
+func TestGoLogger_WithGroupEmptyReturnsReceiver(t *testing.T) {
+ logger := &GoLogger{Level: LevelDebug}
+ same := logger.WithGroup("")
+ assert.Same(t, logger, same, "WithGroup(\"\") should return the same logger")
+}
+
+func TestParseLevel_WhitespaceTrimming(t *testing.T) {
+ tests := []struct {
+ input string
+ expected Level
+ }{
+ {" debug ", LevelDebug},
+ {"\tinfo\n", LevelInfo},
+ {" warn ", LevelWarn},
+ {"\nerror\t", LevelError},
+ }
+
+ for _, tt := range tests {
+ level, err := ParseLevel(tt.input)
+ require.NoError(t, err, "ParseLevel(%q) should not error", tt.input)
+ assert.Equal(t, tt.expected, level)
+ }
+}
+
+// ===========================================================================
+// NopLogger Comprehensive Tests
+// ===========================================================================
+
+func TestNopLogger_AllMethodsAreNoOps(t *testing.T) {
+ nop := NewNop()
+
+ t.Run("Log does not panic at any level", func(t *testing.T) {
+ assert.NotPanics(t, func() {
+ for _, level := range []Level{LevelError, LevelWarn, LevelInfo, LevelDebug} {
+ nop.Log(context.Background(), level, "message",
+ String("k", "v"), Int("n", 1), Bool("b", true))
+ }
+ })
+ })
+
+ t.Run("With returns self", func(t *testing.T) {
+ child := nop.With(String("a", "b"), String("c", "d"))
+ assert.Equal(t, nop, child)
+ })
+
+ t.Run("WithGroup returns self", func(t *testing.T) {
+ child := nop.WithGroup("any_group")
+ assert.Equal(t, nop, child)
+ })
+
+ t.Run("Enabled always false", func(t *testing.T) {
+ for _, level := range []Level{LevelError, LevelWarn, LevelInfo, LevelDebug} {
+ assert.False(t, nop.Enabled(level))
+ }
+ })
+
+ t.Run("Sync returns nil", func(t *testing.T) {
+ assert.NoError(t, nop.Sync(context.Background()))
+ })
+}
+
+func TestNopLogger_InterfaceCompliance(t *testing.T) {
+ var _ Logger = NewNop()
+ var _ Logger = &NopLogger{}
+}
+
+// ===========================================================================
+// MockLogger Verification Tests
+// ===========================================================================
+
+func TestMockLogger_RecordsCalls(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ mock := NewMockLogger(ctrl)
+
+ ctx := context.Background()
+
+ mock.EXPECT().Enabled(LevelInfo).Return(true)
+ mock.EXPECT().Log(ctx, LevelInfo, "hello", String("k", "v"))
+ mock.EXPECT().Sync(ctx).Return(nil)
+
+ assert.True(t, mock.Enabled(LevelInfo))
+ mock.Log(ctx, LevelInfo, "hello", String("k", "v"))
+ assert.NoError(t, mock.Sync(ctx))
+}
+
+func TestMockLogger_WithAndWithGroup(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ mock := NewMockLogger(ctrl)
+
+ childMock := NewMockLogger(ctrl)
+
+ mock.EXPECT().With(String("tenant", "t1")).Return(childMock)
+ mock.EXPECT().WithGroup("audit").Return(childMock)
+
+ child1 := mock.With(String("tenant", "t1"))
+ assert.Equal(t, childMock, child1)
+
+ child2 := mock.WithGroup("audit")
+ assert.Equal(t, childMock, child2)
+}
+
+func TestMockLogger_InterfaceCompliance(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ var _ Logger = NewMockLogger(ctrl)
+}
+
+// ===========================================================================
+// Level String Tests
+// ===========================================================================
+
+func TestLevel_String(t *testing.T) {
+ tests := []struct {
+ level Level
+ expected string
+ }{
+ {LevelError, "error"},
+ {LevelWarn, "warn"},
+ {LevelInfo, "info"},
+ {LevelDebug, "debug"},
+ {Level(255), "unknown"},
+ }
+
+ for _, tt := range tests {
+ assert.Equal(t, tt.expected, tt.level.String())
+ }
+}
+
+// ===========================================================================
+// renderFields Tests
+// ===========================================================================
+
+func TestRenderFields_EmptyReturnsEmpty(t *testing.T) {
+ assert.Equal(t, "", renderFields(nil))
+ assert.Equal(t, "", renderFields([]Field{}))
+}
+
+func TestRenderFields_SingleField(t *testing.T) {
+ result := renderFields([]Field{String("status", "ok")})
+ assert.Equal(t, "[status=ok]", result)
+}
+
+func TestRenderFields_MultipleFields(t *testing.T) {
+ result := renderFields([]Field{
+ String("a", "1"),
+ Int("b", 2),
+ Bool("c", true),
+ })
+ assert.Contains(t, result, "a=1")
+ assert.Contains(t, result, "b=2")
+ assert.Contains(t, result, "c=true")
+}
+
+func TestRenderFields_EmptyKeyFieldSkipped(t *testing.T) {
+ result := renderFields([]Field{String("", "val")})
+ assert.Equal(t, "", result)
+}
+
+func TestRenderFields_SanitizesKeysAndValues(t *testing.T) {
+ result := renderFields([]Field{
+ String("status\ninjection", "value\ninjection"),
+ })
+ assert.NotContains(t, result, "\n")
+ assert.Contains(t, result, `\n`)
+}
+
+// ===========================================================================
+// sanitizeFieldValue Tests
+// ===========================================================================
+
+type testStringer struct{ s string }
+
+func (ts testStringer) String() string { return ts.s }
+
+func TestSanitizeFieldValue(t *testing.T) {
+ tests := []struct {
+ name string
+ input any
+ expected any
+ }{
+ {
+ name: "plain string passthrough",
+ input: "hello",
+ expected: "hello",
+ },
+ {
+ name: "string with newline is sanitized",
+ input: "line1\nline2",
+ expected: `line1\nline2`,
+ },
+ {
+ name: "error with newline is sanitized",
+ input: errors.New("bad\ninput"),
+ expected: `bad\ninput`,
+ },
+ {
+ name: "fmt.Stringer with newline is sanitized",
+ input: testStringer{s: "hello\nworld"},
+ expected: `hello\nworld`,
+ },
+ {
+ name: "integer passes through unchanged",
+ input: 42,
+ expected: 42,
+ },
+ {
+ name: "nil passes through unchanged",
+ input: nil,
+ expected: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := sanitizeFieldValue(tt.input)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
diff --git a/log/nil.go b/log/nil.go
new file mode 100644
index 0000000..c6ab551
--- /dev/null
+++ b/log/nil.go
@@ -0,0 +1,36 @@
+package log
+
+import "context"
+
+// NopLogger is a no-op logger implementation.
+type NopLogger struct{}
+
+// NewNop creates a no-op logger implementation.
+func NewNop() Logger {
+ return &NopLogger{}
+}
+
+// Log drops all log events.
+func (l *NopLogger) Log(_ context.Context, _ Level, _ string, _ ...Field) {}
+
+// With returns the same no-op logger.
+//
+//nolint:ireturn
+func (l *NopLogger) With(_ ...Field) Logger {
+ return l
+}
+
+// WithGroup returns the same no-op logger.
+//
+//nolint:ireturn
+func (l *NopLogger) WithGroup(_ string) Logger {
+ return l
+}
+
+// Enabled always returns false for NopLogger.
+func (l *NopLogger) Enabled(_ Level) bool {
+ return false
+}
+
+// Sync is a no-op and always returns nil.
+func (l *NopLogger) Sync(_ context.Context) error { return nil }
diff --git a/log/nil_test.go b/log/nil_test.go
new file mode 100644
index 0000000..828156d
--- /dev/null
+++ b/log/nil_test.go
@@ -0,0 +1,24 @@
+//go:build unit
+
+package log
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNopLoggerMethods(t *testing.T) {
+ t.Parallel()
+
+ logger := NewNop()
+ logger.Log(context.Background(), LevelInfo, "ignored", String("k", "v"))
+ assert.Same(t, logger, logger.With(String("child", "value")))
+ assert.Same(t, logger, logger.WithGroup("group"))
+ assert.False(t, logger.Enabled(LevelError))
+ assert.False(t, logger.Enabled(LevelWarn))
+ assert.False(t, logger.Enabled(LevelInfo))
+ assert.False(t, logger.Enabled(LevelDebug))
+ assert.NoError(t, logger.Sync(context.Background()))
+}
diff --git a/log/sanitizer.go b/log/sanitizer.go
new file mode 100644
index 0000000..0c70250
--- /dev/null
+++ b/log/sanitizer.go
@@ -0,0 +1,41 @@
+package log
+
+import (
+ "context"
+ "fmt"
+)
+
+// SafeError logs errors with explicit production-aware sanitization.
+// When production is true, only the error type is logged (no message details).
+//
+// Design rationale: the production boolean is caller-supplied rather than
+// derived from a global flag. This keeps the log package free of global state
+// and lets the caller (typically a service boundary) decide the sanitization
+// policy based on its own configuration. Callers in production deployments
+// should pass true to prevent leaking sensitive error details into log output.
+func SafeError(logger Logger, ctx context.Context, msg string, err error, production bool) {
+ if logger == nil {
+ return
+ }
+
+ if err == nil {
+ return
+ }
+
+ if !logger.Enabled(LevelError) {
+ return
+ }
+
+ if production {
+ logger.Log(ctx, LevelError, msg, String("error_type", fmt.Sprintf("%T", err)))
+ return
+ }
+
+ logger.Log(ctx, LevelError, msg, Err(err))
+}
+
+// SanitizeExternalResponse removes potentially sensitive external response data.
+// Returns only status code for error messages.
+func SanitizeExternalResponse(statusCode int) string {
+ return fmt.Sprintf("external system returned status %d", statusCode)
+}
diff --git a/log/sanitizer_test.go b/log/sanitizer_test.go
new file mode 100644
index 0000000..3cce4c5
--- /dev/null
+++ b/log/sanitizer_test.go
@@ -0,0 +1,287 @@
+//go:build unit
+
+package log
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSafeError_ProductionAndNonProduction(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := &GoLogger{Level: LevelDebug}
+ err := errors.New("credential_id=abc123")
+
+ SafeError(logger, context.Background(), "request failed", err, false)
+ assert.Contains(t, buf.String(), "request failed")
+ assert.Contains(t, buf.String(), "credential_id=abc123")
+
+ buf.Reset()
+ SafeError(logger, context.Background(), "request failed", err, true)
+ out := buf.String()
+ assert.Contains(t, out, "request failed")
+ assert.Contains(t, out, "error_type=*errors.errorString")
+ assert.NotContains(t, out, "credential_id=abc123")
+}
+
+func TestSafeError_NilGuards(t *testing.T) {
+ t.Run("nil logger produces no output", func(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ assert.NotPanics(t, func() {
+ SafeError(nil, context.Background(), "msg", assert.AnError, true)
+ })
+ assert.Empty(t, buf.String(), "nil logger must produce no output")
+ })
+
+ t.Run("nil error produces no output", func(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ assert.NotPanics(t, func() {
+ SafeError(&GoLogger{Level: LevelInfo}, context.Background(), "msg", nil, true)
+ })
+ assert.Empty(t, buf.String(), "nil error must produce no output")
+ })
+}
+
+func TestSanitizeExternalResponse(t *testing.T) {
+ assert.Equal(t, "external system returned status 400", SanitizeExternalResponse(400))
+}
+
+// ---------------------------------------------------------------------------
+// CWE-117: Comprehensive sanitizeLogString test matrix
+// ---------------------------------------------------------------------------
+
+func TestSanitizeLogString_ControlCharacterMatrix(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ assertFn func(t *testing.T, result string)
+ }{
+ {
+ name: "LF newline is escaped",
+ input: "line1\nline2",
+ assertFn: func(t *testing.T, result string) {
+ t.Helper()
+ assert.NotContains(t, result, "\n")
+ assert.Contains(t, result, `\n`)
+ },
+ },
+ {
+ name: "CR carriage return is escaped",
+ input: "line1\rline2",
+ assertFn: func(t *testing.T, result string) {
+ t.Helper()
+ assert.NotContains(t, result, "\r")
+ assert.Contains(t, result, `\r`)
+ },
+ },
+ {
+ name: "CRLF is escaped",
+ input: "line1\r\nline2",
+ assertFn: func(t *testing.T, result string) {
+ t.Helper()
+ assert.NotContains(t, result, "\r")
+ assert.NotContains(t, result, "\n")
+ assert.Contains(t, result, `\r`)
+ assert.Contains(t, result, `\n`)
+ },
+ },
+ {
+ name: "tab character is escaped",
+ input: "field1\tfield2",
+ assertFn: func(t *testing.T, result string) {
+ t.Helper()
+ assert.NotContains(t, result, "\t")
+ assert.Contains(t, result, `\t`)
+ },
+ },
+ {
+ name: "null byte is removed or escaped",
+ input: "before\x00after",
+ assertFn: func(t *testing.T, result string) {
+ t.Helper()
+ assert.NotContains(t, result, "\x00")
+ },
+ },
+ {
+ name: "normal ASCII passes through unchanged",
+ input: "hello world 123 !@#$%",
+ assertFn: func(t *testing.T, result string) {
+ t.Helper()
+ assert.Equal(t, "hello world 123 !@#$%", result)
+ },
+ },
+ {
+ name: "empty string passes through",
+ input: "",
+ assertFn: func(t *testing.T, result string) {
+ t.Helper()
+ assert.Equal(t, "", result)
+ },
+ },
+ {
+ name: "legitimate Unicode text passes through",
+ input: "Hello, \u4e16\u754c! Ol\u00e1! \u00dcber!",
+ assertFn: func(t *testing.T, result string) {
+ t.Helper()
+ assert.Contains(t, result, "\u4e16\u754c")
+ assert.Contains(t, result, "Ol\u00e1")
+ },
+ },
+ {
+ name: "multiple newlines in single string",
+ input: "line1\nline2\nline3\nline4",
+ assertFn: func(t *testing.T, result string) {
+ t.Helper()
+ assert.NotContains(t, result, "\n")
+ assert.Equal(t, 3, strings.Count(result, `\n`))
+ },
+ },
+ {
+ name: "mixed control characters",
+ input: "start\nmiddle\rend\ttab",
+ assertFn: func(t *testing.T, result string) {
+ t.Helper()
+ assert.NotContains(t, result, "\n")
+ assert.NotContains(t, result, "\r")
+ assert.NotContains(t, result, "\t")
+ },
+ },
+ {
+ name: "very long string with embedded control chars",
+ input: strings.Repeat("a", 5000) + "\n" + strings.Repeat("b", 5000),
+ assertFn: func(t *testing.T, result string) {
+ t.Helper()
+ assert.NotContains(t, result, "\n")
+ assert.Contains(t, result, `\n`)
+ assert.True(t, len(result) > 10000)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := sanitizeLogString(tt.input)
+ tt.assertFn(t, result)
+ })
+ }
+}
+
+func TestSanitizeFieldValue_TypeDispatch(t *testing.T) {
+ t.Run("string values are sanitized", func(t *testing.T) {
+ result := sanitizeFieldValue("value\ninjected")
+ s, ok := result.(string)
+ require.True(t, ok)
+ assert.NotContains(t, s, "\n")
+ assert.Contains(t, s, `\n`)
+ })
+
+ t.Run("integer values pass through", func(t *testing.T) {
+ result := sanitizeFieldValue(42)
+ assert.Equal(t, 42, result)
+ })
+
+ t.Run("boolean values pass through", func(t *testing.T) {
+ result := sanitizeFieldValue(true)
+ assert.Equal(t, true, result)
+ })
+
+ t.Run("nil values pass through", func(t *testing.T) {
+ result := sanitizeFieldValue(nil)
+ assert.Nil(t, result)
+ })
+
+ t.Run("error values are sanitized", func(t *testing.T) {
+ err := errors.New("some error\nwith newline")
+ result := sanitizeFieldValue(err)
+ s, ok := result.(string)
+ require.True(t, ok, "error values should be converted to sanitized strings")
+ assert.NotContains(t, s, "\n")
+ assert.Contains(t, s, `\n`)
+ assert.Equal(t, `some error\nwith newline`, s)
+ })
+
+ t.Run("struct values with newlines are sanitized", func(t *testing.T) {
+ type payload struct {
+ Msg string
+ }
+ result := sanitizeFieldValue(payload{Msg: "line1\nline2"})
+ s, ok := result.(string)
+ require.True(t, ok, "composite types should be serialized to sanitized strings")
+ assert.NotContains(t, s, "\n")
+ })
+
+ t.Run("slice values with newlines are sanitized", func(t *testing.T) {
+ result := sanitizeFieldValue([]string{"a\nb", "c"})
+ s, ok := result.(string)
+ require.True(t, ok, "slices should be serialized to sanitized strings")
+ assert.NotContains(t, s, "\n")
+ })
+
+ t.Run("map values with newlines are sanitized", func(t *testing.T) {
+ result := sanitizeFieldValue(map[string]string{"k": "v\ninjected"})
+ s, ok := result.(string)
+ require.True(t, ok, "maps should be serialized to sanitized strings")
+ assert.NotContains(t, s, "\n")
+ })
+
+ t.Run("typed-nil error returns placeholder", func(t *testing.T) {
+ var err *customError // typed nil
+ result := sanitizeFieldValue(err)
+ assert.Equal(t, "", result,
+ "typed-nil error should return placeholder, not panic")
+ })
+
+ t.Run("typed-nil stringer returns placeholder", func(t *testing.T) {
+ var s *testStringer // typed nil
+ result := sanitizeFieldValue(s)
+ assert.Equal(t, "", result,
+ "typed-nil Stringer should return placeholder, not panic")
+ })
+}
+
+// customError is a typed error for testing typed-nil behavior.
+type customError struct{ msg string }
+
+func (e *customError) Error() string { return e.msg }
+
+func TestSafeError_LevelFiltering(t *testing.T) {
+ var buf bytes.Buffer
+ withTestLoggerOutput(t, &buf)
+
+ logger := &GoLogger{Level: LevelWarn}
+
+ SafeError(logger, context.Background(), "should appear", errors.New("err"), false)
+ assert.Contains(t, buf.String(), "should appear")
+}
+
+func TestSanitizeExternalResponse_VariousCodes(t *testing.T) {
+ tests := []struct {
+ code int
+ expected string
+ }{
+ {200, "external system returned status 200"},
+ {400, "external system returned status 400"},
+ {401, "external system returned status 401"},
+ {403, "external system returned status 403"},
+ {404, "external system returned status 404"},
+ {500, "external system returned status 500"},
+ {502, "external system returned status 502"},
+ {503, "external system returned status 503"},
+ }
+
+ for _, tt := range tests {
+ assert.Equal(t, tt.expected, SanitizeExternalResponse(tt.code))
+ }
+}
diff --git a/metrics/account.go b/metrics/account.go
new file mode 100644
index 0000000..3a2bbf3
--- /dev/null
+++ b/metrics/account.go
@@ -0,0 +1,21 @@
+package metrics
+
+import (
+ "context"
+
+ "go.opentelemetry.io/otel/attribute"
+)
+
+// RecordAccountCreated increments the account-created counter.
+func (f *MetricsFactory) RecordAccountCreated(ctx context.Context, attributes ...attribute.KeyValue) error {
+ if f == nil {
+ return ErrNilFactory
+ }
+
+ b, err := f.Counter(MetricAccountsCreated)
+ if err != nil {
+ return err
+ }
+
+ return b.WithAttributes(attributes...).AddOne(ctx)
+}
diff --git a/metrics/builders.go b/metrics/builders.go
new file mode 100644
index 0000000..e865000
--- /dev/null
+++ b/metrics/builders.go
@@ -0,0 +1,243 @@
+package metrics
+
+import (
+ "context"
+ "errors"
+
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/metric"
+)
+
+var (
+ // ErrNilCounter is returned when a counter builder has no instrument.
+ ErrNilCounter = errors.New("counter instrument is nil")
+ // ErrNilGauge is returned when a gauge builder has no instrument.
+ ErrNilGauge = errors.New("gauge instrument is nil")
+ // ErrNilHistogram is returned when a histogram builder has no instrument.
+ ErrNilHistogram = errors.New("histogram instrument is nil")
+ // ErrNilCounterBuilder is returned when a CounterBuilder method is called on a nil receiver.
+ ErrNilCounterBuilder = errors.New("counter builder is nil")
+ // ErrNilGaugeBuilder is returned when a GaugeBuilder method is called on a nil receiver.
+ ErrNilGaugeBuilder = errors.New("gauge builder is nil")
+ // ErrNilHistogramBuilder is returned when a HistogramBuilder method is called on a nil receiver.
+ ErrNilHistogramBuilder = errors.New("histogram builder is nil")
+)
+
+// CounterBuilder provides a fluent API for recording counter metrics with optional labels
+type CounterBuilder struct {
+ factory *MetricsFactory
+ counter metric.Int64Counter
+ name string
+ attrs []attribute.KeyValue
+}
+
+// WithLabels adds labels/attributes to the counter metric.
+// Returns a nil-safe builder if the receiver is nil.
+func (c *CounterBuilder) WithLabels(labels map[string]string) *CounterBuilder {
+ if c == nil {
+ return nil
+ }
+
+ builder := &CounterBuilder{
+ factory: c.factory,
+ counter: c.counter,
+ name: c.name,
+ attrs: make([]attribute.KeyValue, 0, len(c.attrs)+len(labels)),
+ }
+
+ builder.attrs = append(builder.attrs, c.attrs...)
+
+ for key, value := range labels {
+ builder.attrs = append(builder.attrs, attribute.String(key, value))
+ }
+
+ return builder
+}
+
+// WithAttributes adds OpenTelemetry attributes to the counter metric.
+// Returns a nil-safe builder if the receiver is nil.
+func (c *CounterBuilder) WithAttributes(attrs ...attribute.KeyValue) *CounterBuilder {
+ if c == nil {
+ return nil
+ }
+
+ builder := &CounterBuilder{
+ factory: c.factory,
+ counter: c.counter,
+ name: c.name,
+ attrs: make([]attribute.KeyValue, 0, len(c.attrs)+len(attrs)),
+ }
+
+ builder.attrs = append(builder.attrs, c.attrs...)
+
+ builder.attrs = append(builder.attrs, attrs...)
+
+ return builder
+}
+
+// Add records a counter increment.
+// Returns an error if the value is negative (counters are monotonically increasing).
+func (c *CounterBuilder) Add(ctx context.Context, value int64) error {
+ if c == nil {
+ return ErrNilCounterBuilder
+ }
+
+ if c.counter == nil {
+ return ErrNilCounter
+ }
+
+ if value < 0 {
+ return ErrNegativeCounterValue
+ }
+
+ c.counter.Add(ctx, value, metric.WithAttributes(c.attrs...))
+
+ return nil
+}
+
+// AddOne increments the counter by one.
+func (c *CounterBuilder) AddOne(ctx context.Context) error {
+ if c == nil {
+ return ErrNilCounterBuilder
+ }
+
+ return c.Add(ctx, 1)
+}
+
+// GaugeBuilder provides a fluent API for recording gauge metrics with optional labels
+type GaugeBuilder struct {
+ factory *MetricsFactory
+ gauge metric.Int64Gauge
+ name string
+ attrs []attribute.KeyValue
+}
+
+// WithLabels adds labels/attributes to the gauge metric.
+// Returns a nil-safe builder if the receiver is nil.
+func (g *GaugeBuilder) WithLabels(labels map[string]string) *GaugeBuilder {
+ if g == nil {
+ return nil
+ }
+
+ builder := &GaugeBuilder{
+ factory: g.factory,
+ gauge: g.gauge,
+ name: g.name,
+ attrs: make([]attribute.KeyValue, 0, len(g.attrs)+len(labels)),
+ }
+
+ builder.attrs = append(builder.attrs, g.attrs...)
+
+ for key, value := range labels {
+ builder.attrs = append(builder.attrs, attribute.String(key, value))
+ }
+
+ return builder
+}
+
+// WithAttributes adds OpenTelemetry attributes to the gauge metric.
+// Returns a nil-safe builder if the receiver is nil.
+func (g *GaugeBuilder) WithAttributes(attrs ...attribute.KeyValue) *GaugeBuilder {
+ if g == nil {
+ return nil
+ }
+
+ builder := &GaugeBuilder{
+ factory: g.factory,
+ gauge: g.gauge,
+ name: g.name,
+ attrs: make([]attribute.KeyValue, 0, len(g.attrs)+len(attrs)),
+ }
+
+ builder.attrs = append(builder.attrs, g.attrs...)
+
+ builder.attrs = append(builder.attrs, attrs...)
+
+ return builder
+}
+
+// Set sets the current value of a gauge (recommended for application code).
+//
+// This is the primary implementation for recording gauge values and is
+// idiomatic for instantaneous state (e.g., queue length, in-flight operations).
+// It uses only the builder attributes to avoid high-cardinality labels.
+func (g *GaugeBuilder) Set(ctx context.Context, value int64) error {
+ if g == nil {
+ return ErrNilGaugeBuilder
+ }
+
+ if g.gauge == nil {
+ return ErrNilGauge
+ }
+
+ g.gauge.Record(ctx, value, metric.WithAttributes(g.attrs...))
+
+ return nil
+}
+
+// HistogramBuilder provides a fluent API for recording histogram metrics with optional labels
+type HistogramBuilder struct {
+ factory *MetricsFactory
+ histogram metric.Int64Histogram
+ name string
+ attrs []attribute.KeyValue
+}
+
+// WithLabels adds labels/attributes to the histogram metric.
+// Returns a nil-safe builder if the receiver is nil.
+func (h *HistogramBuilder) WithLabels(labels map[string]string) *HistogramBuilder {
+ if h == nil {
+ return nil
+ }
+
+ builder := &HistogramBuilder{
+ factory: h.factory,
+ histogram: h.histogram,
+ name: h.name,
+ attrs: make([]attribute.KeyValue, 0, len(h.attrs)+len(labels)),
+ }
+
+ builder.attrs = append(builder.attrs, h.attrs...)
+
+ for key, value := range labels {
+ builder.attrs = append(builder.attrs, attribute.String(key, value))
+ }
+
+ return builder
+}
+
+// WithAttributes adds OpenTelemetry attributes to the histogram metric.
+// Returns a nil-safe builder if the receiver is nil.
+func (h *HistogramBuilder) WithAttributes(attrs ...attribute.KeyValue) *HistogramBuilder {
+ if h == nil {
+ return nil
+ }
+
+ builder := &HistogramBuilder{
+ factory: h.factory,
+ histogram: h.histogram,
+ name: h.name,
+ attrs: make([]attribute.KeyValue, 0, len(h.attrs)+len(attrs)),
+ }
+
+ builder.attrs = append(builder.attrs, h.attrs...)
+
+ builder.attrs = append(builder.attrs, attrs...)
+
+ return builder
+}
+
+// Record records a histogram value
+func (h *HistogramBuilder) Record(ctx context.Context, value int64) error {
+ if h == nil {
+ return ErrNilHistogramBuilder
+ }
+
+ if h.histogram == nil {
+ return ErrNilHistogram
+ }
+
+ h.histogram.Record(ctx, value, metric.WithAttributes(h.attrs...))
+
+ return nil
+}
diff --git a/metrics/doc.go b/metrics/doc.go
new file mode 100644
index 0000000..f9ff7b3
--- /dev/null
+++ b/metrics/doc.go
@@ -0,0 +1,5 @@
+// Package metrics provides a thread-safe MetricsFactory with lazy instrument caching
+// and a fluent builder API for Counters, Gauges, and Histograms. All operations return
+// explicit errors. Includes pre-configured domain metric recorders and system
+// infrastructure gauges.
+package metrics
diff --git a/metrics/metrics.go b/metrics/metrics.go
new file mode 100644
index 0000000..44abc0e
--- /dev/null
+++ b/metrics/metrics.go
@@ -0,0 +1,388 @@
+package metrics
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/LerianStudio/lib-observability/log"
+ "go.opentelemetry.io/otel/metric"
+ "go.opentelemetry.io/otel/metric/noop"
+)
+
+// MetricsFactory provides a thread-safe factory for creating and managing OpenTelemetry metrics
+// with lazy initialization using sync.Map for high-performance concurrent access.
+type MetricsFactory struct {
+ meter metric.Meter
+ counters sync.Map // string -> metric.Int64Counter
+ gauges sync.Map // string -> metric.Int64Gauge
+ histograms sync.Map // string -> metric.Int64Histogram
+ logger log.Logger
+}
+
+var (
+ // ErrNilMeter indicates that a nil OTEL meter was provided.
+ ErrNilMeter = errors.New("metric meter cannot be nil")
+ // ErrNilFactory is returned when a MetricsFactory method is called on a nil receiver.
+ ErrNilFactory = errors.New("metrics factory is nil")
+ // ErrNegativeCounterValue is returned when a negative value is passed to Counter.Add.
+ ErrNegativeCounterValue = errors.New("counter value must not be negative")
+ // ErrPercentageOutOfRange is returned when a percentage value is outside [0, 100].
+ ErrPercentageOutOfRange = errors.New("percentage value must be between 0 and 100")
+)
+
+// Metric represents a metric that can be collected by the server.
+type Metric struct {
+ Name string
+ Description string
+ Unit string
+ // For histograms: bucket boundaries
+ Buckets []float64
+}
+
+// Pre-configured metrics that can be used to create metrics with default options.
+var (
+ // MetricAccountsCreated is a metric that measures the number of accounts created by the server.
+ MetricAccountsCreated = Metric{
+ Name: "accounts_created",
+ Unit: "1",
+ Description: "Measures the number of accounts created by the server.",
+ }
+
+ // MetricTransactionsProcessed is a metric that measures the number of transactions processed by the server.
+ MetricTransactionsProcessed = Metric{
+ Name: "transactions_processed",
+ Unit: "1",
+ Description: "Measures the number of transactions processed by the server.",
+ }
+
+ // MetricTransactionRoutesCreated is a metric that measures the number of transaction routes created by the server.
+ MetricTransactionRoutesCreated = Metric{
+ Name: "transaction_routes_created",
+ Unit: "1",
+ Description: "Measures the number of transaction routes created by the server.",
+ }
+
+ // MetricOperationRoutesCreated is a metric that measures the number of operation routes created by the server.
+ MetricOperationRoutesCreated = Metric{
+ Name: "operation_routes_created",
+ Unit: "1",
+ Description: "Measures the number of operation routes created by the server.",
+ }
+)
+
+// Default histogram bucket configurations for different metric types.
+// Values are in seconds for consistency with OpenTelemetry conventions.
+var (
+ // DefaultLatencyBuckets for latency measurements (in seconds)
+ DefaultLatencyBuckets = []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
+
+ // DefaultAccountBuckets for account creation counts
+ DefaultAccountBuckets = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000}
+
+ // DefaultTransactionBuckets for transaction count per time period
+ DefaultTransactionBuckets = []float64{1, 10, 50, 100, 500, 1000, 2500, 5000, 8000, 10000}
+)
+
+// NewMetricsFactory creates a new MetricsFactory instance.
+func NewMetricsFactory(meter metric.Meter, logger log.Logger) (*MetricsFactory, error) {
+ if meter == nil {
+ return nil, ErrNilMeter
+ }
+
+ return &MetricsFactory{
+ meter: meter,
+ logger: logger,
+ }, nil
+}
+
+// NewNopFactory returns a MetricsFactory backed by OpenTelemetry's no-op meter.
+// It is safe for use as a fallback when a real meter is unavailable.
+func NewNopFactory() *MetricsFactory {
+ return &MetricsFactory{
+ meter: noop.NewMeterProvider().Meter("nop"),
+ logger: log.NewNop(),
+ }
+}
+
+// Counter creates or retrieves a counter metric and returns a builder for fluent API usage
+func (f *MetricsFactory) Counter(m Metric) (*CounterBuilder, error) {
+ if f == nil {
+ return nil, ErrNilFactory
+ }
+
+ counter, err := f.getOrCreateCounter(m)
+ if err != nil {
+ return nil, err
+ }
+
+ return &CounterBuilder{
+ factory: f,
+ counter: counter,
+ name: m.Name,
+ }, nil
+}
+
+// Gauge creates or retrieves a gauge metric and returns a builder for fluent API usage
+func (f *MetricsFactory) Gauge(m Metric) (*GaugeBuilder, error) {
+ if f == nil {
+ return nil, ErrNilFactory
+ }
+
+ gauge, err := f.getOrCreateGauge(m)
+ if err != nil {
+ return nil, err
+ }
+
+ return &GaugeBuilder{
+ factory: f,
+ gauge: gauge,
+ name: m.Name,
+ }, nil
+}
+
+// Histogram creates or retrieves a histogram metric and returns a builder for fluent API usage
+func (f *MetricsFactory) Histogram(m Metric) (*HistogramBuilder, error) {
+ if f == nil {
+ return nil, ErrNilFactory
+ }
+
+ // Set default buckets if not provided
+ if m.Buckets == nil {
+ m.Buckets = selectDefaultBuckets(m.Name)
+ }
+
+ histogram, err := f.getOrCreateHistogram(m)
+ if err != nil {
+ return nil, err
+ }
+
+ return &HistogramBuilder{
+ factory: f,
+ histogram: histogram,
+ name: m.Name,
+ }, nil
+}
+
+// selectDefaultBuckets chooses default buckets based on metric name.
+// Uses exact match first, then checks for substrings in a deterministic order.
+func selectDefaultBuckets(name string) []float64 {
+ nameL := strings.ToLower(name)
+
+ // Check substrings in deterministic priority order.
+ // Latency/duration/time patterns first to avoid "transaction_latency"
+ // matching "transaction" instead of "latency".
+ patterns := []struct {
+ substr string
+ buckets []float64
+ }{
+ {"latency", DefaultLatencyBuckets},
+ {"duration", DefaultLatencyBuckets},
+ {"time", DefaultLatencyBuckets},
+ {"account", DefaultAccountBuckets},
+ {"transaction", DefaultTransactionBuckets},
+ }
+
+ for _, p := range patterns {
+ if strings.Contains(nameL, p.substr) {
+ return p.buckets
+ }
+ }
+
+ return DefaultLatencyBuckets
+}
+
+// getOrCreateCounter lazily creates or retrieves an existing counter
+func (f *MetricsFactory) getOrCreateCounter(m Metric) (metric.Int64Counter, error) {
+ if f == nil {
+ return nil, ErrNilFactory
+ }
+
+ if counter, exists := f.counters.Load(m.Name); exists {
+ if c, ok := counter.(metric.Int64Counter); ok {
+ return c, nil
+ }
+
+ return nil, fmt.Errorf("counter cache contains invalid type for %q", m.Name)
+ }
+
+ // Create new counter with proper options
+ counterOpts := f.addCounterOptions(m)
+
+ counter, err := f.meter.Int64Counter(m.Name, counterOpts...)
+ if err != nil {
+ if f.logger != nil {
+ f.logger.Log(context.Background(), log.LevelError, "failed to create counter metric", log.String("metric_name", m.Name), log.Err(err))
+ }
+
+ return nil, fmt.Errorf("create counter %q: %w", m.Name, err)
+ }
+
+ // Store in sync.Map for future use
+ if actual, loaded := f.counters.LoadOrStore(m.Name, counter); loaded {
+ // Another goroutine created it first, use that one
+ if c, ok := actual.(metric.Int64Counter); ok {
+ return c, nil
+ }
+
+ return nil, fmt.Errorf("counter cache contains invalid type for %q", m.Name)
+ }
+
+ return counter, nil
+}
+
+// getOrCreateGauge lazily creates or retrieves an existing gauge
+func (f *MetricsFactory) getOrCreateGauge(m Metric) (metric.Int64Gauge, error) {
+ if f == nil {
+ return nil, ErrNilFactory
+ }
+
+ if gauge, exists := f.gauges.Load(m.Name); exists {
+ if g, ok := gauge.(metric.Int64Gauge); ok {
+ return g, nil
+ }
+
+ return nil, fmt.Errorf("gauge cache contains invalid type for %q", m.Name)
+ }
+
+ // Create new gauge with proper options
+ gaugeOpts := f.addGaugeOptions(m)
+
+ gauge, err := f.meter.Int64Gauge(m.Name, gaugeOpts...)
+ if err != nil {
+ if f.logger != nil {
+ f.logger.Log(context.Background(), log.LevelError, "failed to create gauge metric", log.String("metric_name", m.Name), log.Err(err))
+ }
+
+ return nil, fmt.Errorf("create gauge %q: %w", m.Name, err)
+ }
+
+ // Store in sync.Map for future use
+ if actual, loaded := f.gauges.LoadOrStore(m.Name, gauge); loaded {
+ // Another goroutine created it first, use that one
+ if g, ok := actual.(metric.Int64Gauge); ok {
+ return g, nil
+ }
+
+ return nil, fmt.Errorf("gauge cache contains invalid type for %q", m.Name)
+ }
+
+ return gauge, nil
+}
+
+// getOrCreateHistogram lazily creates or retrieves an existing histogram.
+// Uses a composite key (name + buckets hash) to ensure different bucket configs
+// result in different histograms.
+func (f *MetricsFactory) getOrCreateHistogram(m Metric) (metric.Int64Histogram, error) {
+ if f == nil {
+ return nil, ErrNilFactory
+ }
+
+ // Sort buckets before both cache key computation and instrument creation
+ // to ensure the instrument configuration matches the cache key.
+ if len(m.Buckets) > 1 {
+ sorted := make([]float64, len(m.Buckets))
+ copy(sorted, m.Buckets)
+ sort.Float64s(sorted)
+ m.Buckets = sorted
+ }
+
+ cacheKey := histogramCacheKey(m.Name, m.Buckets)
+
+ if histogram, exists := f.histograms.Load(cacheKey); exists {
+ if h, ok := histogram.(metric.Int64Histogram); ok {
+ return h, nil
+ }
+
+ return nil, fmt.Errorf("histogram cache contains invalid type for %q", cacheKey)
+ }
+
+ // Create new histogram with proper options
+ histogramOpts := f.addHistogramOptions(m)
+
+ histogram, err := f.meter.Int64Histogram(m.Name, histogramOpts...)
+ if err != nil {
+ if f.logger != nil {
+ f.logger.Log(context.Background(), log.LevelError, "failed to create histogram metric", log.String("metric_name", m.Name), log.Err(err))
+ }
+
+ return nil, fmt.Errorf("create histogram %q: %w", m.Name, err)
+ }
+
+ // Store in sync.Map for future use
+ if actual, loaded := f.histograms.LoadOrStore(cacheKey, histogram); loaded {
+ // Another goroutine created it first, use that one
+ if h, ok := actual.(metric.Int64Histogram); ok {
+ return h, nil
+ }
+
+ return nil, fmt.Errorf("histogram cache contains invalid type for %q", cacheKey)
+ }
+
+ return histogram, nil
+}
+
+// histogramCacheKey generates a unique cache key based on name and bucket configuration.
+func histogramCacheKey(name string, buckets []float64) string {
+ if len(buckets) == 0 {
+ return name
+ }
+
+ sortedBuckets := make([]float64, len(buckets))
+ copy(sortedBuckets, buckets)
+ sort.Float64s(sortedBuckets)
+
+ bucketStrings := make([]string, len(sortedBuckets))
+ for i, b := range sortedBuckets {
+ bucketStrings[i] = strconv.FormatFloat(b, 'g', -1, 64)
+ }
+
+ return fmt.Sprintf("%s:%s", name, strings.Join(bucketStrings, ","))
+}
+
+func (f *MetricsFactory) addCounterOptions(m Metric) []metric.Int64CounterOption {
+ var opts []metric.Int64CounterOption
+ if m.Description != "" {
+ opts = append(opts, metric.WithDescription(m.Description))
+ }
+
+ if m.Unit != "" {
+ opts = append(opts, metric.WithUnit(m.Unit))
+ }
+
+ return opts
+}
+
+func (f *MetricsFactory) addGaugeOptions(m Metric) []metric.Int64GaugeOption {
+ var opts []metric.Int64GaugeOption
+ if m.Description != "" {
+ opts = append(opts, metric.WithDescription(m.Description))
+ }
+
+ if m.Unit != "" {
+ opts = append(opts, metric.WithUnit(m.Unit))
+ }
+
+ return opts
+}
+
+func (f *MetricsFactory) addHistogramOptions(m Metric) []metric.Int64HistogramOption {
+ var opts []metric.Int64HistogramOption
+ if m.Description != "" {
+ opts = append(opts, metric.WithDescription(m.Description))
+ }
+
+ if m.Unit != "" {
+ opts = append(opts, metric.WithUnit(m.Unit))
+ }
+
+ if m.Buckets != nil {
+ opts = append(opts, metric.WithExplicitBucketBoundaries(m.Buckets...))
+ }
+
+ return opts
+}
diff --git a/metrics/metrics_test.go b/metrics/metrics_test.go
new file mode 100644
index 0000000..9a04334
--- /dev/null
+++ b/metrics/metrics_test.go
@@ -0,0 +1,256 @@
+//go:build unit
+
+package metrics
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/LerianStudio/lib-observability/log"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/metric"
+ "go.opentelemetry.io/otel/metric/noop"
+)
+
+func newTestFactory(t *testing.T) *MetricsFactory {
+ t.Helper()
+
+ f, err := NewMetricsFactory(noop.NewMeterProvider().Meter("metrics-test"), log.NewNop())
+ require.NoError(t, err)
+
+ return f
+}
+
+type cacheTestMeter struct {
+ metric.Meter
+ counter *cacheTestCounter
+ gauge *cacheTestGauge
+ histogram *cacheTestHistogram
+}
+
+type cacheTestCounter struct{ metric.Int64Counter }
+type cacheTestGauge struct{ metric.Int64Gauge }
+type cacheTestHistogram struct{ metric.Int64Histogram }
+
+func newCacheTestFactory(t *testing.T) *MetricsFactory {
+ t.Helper()
+
+ meter := noop.NewMeterProvider().Meter("metrics-cache-test")
+ counter, err := meter.Int64Counter("counter")
+ require.NoError(t, err)
+ gauge, err := meter.Int64Gauge("gauge")
+ require.NoError(t, err)
+ histogram, err := meter.Int64Histogram("histogram")
+ require.NoError(t, err)
+
+ f, err := NewMetricsFactory(&cacheTestMeter{
+ Meter: meter,
+ counter: &cacheTestCounter{Int64Counter: counter},
+ gauge: &cacheTestGauge{Int64Gauge: gauge},
+ histogram: &cacheTestHistogram{Int64Histogram: histogram},
+ }, log.NewNop())
+ require.NoError(t, err)
+
+ return f
+}
+
+func (m *cacheTestMeter) Int64Counter(string, ...metric.Int64CounterOption) (metric.Int64Counter, error) {
+ return m.counter, nil
+}
+
+func (m *cacheTestMeter) Int64Gauge(string, ...metric.Int64GaugeOption) (metric.Int64Gauge, error) {
+ return m.gauge, nil
+}
+
+func (m *cacheTestMeter) Int64Histogram(string, ...metric.Int64HistogramOption) (metric.Int64Histogram, error) {
+ return m.histogram, nil
+}
+
+func TestNewMetricsFactory(t *testing.T) {
+ t.Parallel()
+
+ f, err := NewMetricsFactory(noop.NewMeterProvider().Meter("metrics-test"), nil)
+ require.NoError(t, err)
+ require.NotNil(t, f)
+
+ f, err = NewMetricsFactory(nil, nil)
+ require.Nil(t, f)
+ assert.ErrorIs(t, err, ErrNilMeter)
+}
+
+func TestNewNopFactoryRecords(t *testing.T) {
+ t.Parallel()
+
+ f := NewNopFactory()
+ require.NotNil(t, f)
+
+ ctx := context.Background()
+ assert.NoError(t, f.RecordAccountCreated(ctx))
+ assert.NoError(t, f.RecordTransactionProcessed(ctx))
+ assert.NoError(t, f.RecordTransactionRouteCreated(ctx))
+ assert.NoError(t, f.RecordOperationRouteCreated(ctx))
+ assert.NoError(t, f.RecordSystemCPUUsage(ctx, 0))
+ assert.NoError(t, f.RecordSystemCPUUsage(ctx, 100))
+ assert.NoError(t, f.RecordSystemMemUsage(ctx, 42))
+}
+
+func TestMetricsFactoryNilReceiver(t *testing.T) {
+ t.Parallel()
+
+ var f *MetricsFactory
+ ctx := context.Background()
+
+ _, err := f.Counter(MetricAccountsCreated)
+ assert.ErrorIs(t, err, ErrNilFactory)
+ _, err = f.Gauge(MetricSystemCPUUsage)
+ assert.ErrorIs(t, err, ErrNilFactory)
+ _, err = f.Histogram(Metric{Name: "duration"})
+ assert.ErrorIs(t, err, ErrNilFactory)
+ assert.ErrorIs(t, f.RecordAccountCreated(ctx), ErrNilFactory)
+ assert.ErrorIs(t, f.RecordTransactionProcessed(ctx), ErrNilFactory)
+ assert.ErrorIs(t, f.RecordTransactionRouteCreated(ctx), ErrNilFactory)
+ assert.ErrorIs(t, f.RecordOperationRouteCreated(ctx), ErrNilFactory)
+ assert.ErrorIs(t, f.RecordSystemCPUUsage(ctx, 50), ErrNilFactory)
+ assert.ErrorIs(t, f.RecordSystemMemUsage(ctx, 50), ErrNilFactory)
+}
+
+func TestCounterBuilder(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ f := newTestFactory(t)
+ b, err := f.Counter(Metric{Name: "counter_test", Description: "desc", Unit: "1"})
+ require.NoError(t, err)
+
+ withLabels := b.WithLabels(map[string]string{"tenant": "t1"})
+ require.NotSame(t, b, withLabels)
+ assert.Len(t, withLabels.attrs, 1)
+ assert.Empty(t, b.attrs)
+
+ withAttrs := withLabels.WithAttributes(attribute.String("region", "br"))
+ assert.Len(t, withAttrs.attrs, 2)
+ assert.NoError(t, withAttrs.Add(ctx, 2))
+ assert.NoError(t, withAttrs.AddOne(ctx))
+ assert.ErrorIs(t, withAttrs.Add(ctx, -1), ErrNegativeCounterValue)
+
+ var nilBuilder *CounterBuilder
+ assert.Nil(t, nilBuilder.WithLabels(map[string]string{"x": "y"}))
+ assert.Nil(t, nilBuilder.WithAttributes(attribute.String("x", "y")))
+ assert.ErrorIs(t, nilBuilder.Add(ctx, 1), ErrNilCounterBuilder)
+ assert.ErrorIs(t, nilBuilder.AddOne(ctx), ErrNilCounterBuilder)
+ assert.ErrorIs(t, (&CounterBuilder{}).Add(ctx, 1), ErrNilCounter)
+}
+
+func TestGaugeBuilder(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ f := newTestFactory(t)
+ b, err := f.Gauge(Metric{Name: "gauge_test", Description: "desc", Unit: "1"})
+ require.NoError(t, err)
+
+ withLabels := b.WithLabels(map[string]string{"tenant": "t1"})
+ withAttrs := withLabels.WithAttributes(attribute.String("region", "br"))
+ assert.Len(t, withAttrs.attrs, 2)
+ assert.NoError(t, withAttrs.Set(ctx, 42))
+
+ var nilBuilder *GaugeBuilder
+ assert.Nil(t, nilBuilder.WithLabels(map[string]string{"x": "y"}))
+ assert.Nil(t, nilBuilder.WithAttributes(attribute.String("x", "y")))
+ assert.ErrorIs(t, nilBuilder.Set(ctx, 1), ErrNilGaugeBuilder)
+ assert.ErrorIs(t, (&GaugeBuilder{}).Set(ctx, 1), ErrNilGauge)
+}
+
+func TestHistogramBuilder(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ f := newTestFactory(t)
+ b, err := f.Histogram(Metric{Name: "request_duration", Description: "desc", Unit: "ms"})
+ require.NoError(t, err)
+
+ withLabels := b.WithLabels(map[string]string{"tenant": "t1"})
+ withAttrs := withLabels.WithAttributes(attribute.String("region", "br"))
+ assert.Len(t, withAttrs.attrs, 2)
+ assert.NoError(t, withAttrs.Record(ctx, 123))
+
+ var nilBuilder *HistogramBuilder
+ assert.Nil(t, nilBuilder.WithLabels(map[string]string{"x": "y"}))
+ assert.Nil(t, nilBuilder.WithAttributes(attribute.String("x", "y")))
+ assert.ErrorIs(t, nilBuilder.Record(ctx, 1), ErrNilHistogramBuilder)
+ assert.ErrorIs(t, (&HistogramBuilder{}).Record(ctx, 1), ErrNilHistogram)
+}
+
+func TestMetricsFactoryCachesAndInvalidCacheEntries(t *testing.T) {
+ t.Parallel()
+
+ f := newCacheTestFactory(t)
+
+ c1, err := f.getOrCreateCounter(Metric{Name: "cached_counter"})
+ require.NoError(t, err)
+ c2, err := f.getOrCreateCounter(Metric{Name: "cached_counter"})
+ require.NoError(t, err)
+ assert.Same(t, c1, c2)
+
+ g1, err := f.getOrCreateGauge(Metric{Name: "cached_gauge"})
+ require.NoError(t, err)
+ g2, err := f.getOrCreateGauge(Metric{Name: "cached_gauge"})
+ require.NoError(t, err)
+ assert.Same(t, g1, g2)
+
+ h1, err := f.getOrCreateHistogram(Metric{Name: "cached_histogram", Buckets: []float64{5, 1, 2}})
+ require.NoError(t, err)
+ h2, err := f.getOrCreateHistogram(Metric{Name: "cached_histogram", Buckets: []float64{2, 5, 1}})
+ require.NoError(t, err)
+ assert.Same(t, h1, h2)
+
+ f.counters.Store("bad_counter", "wrong")
+ _, err = f.getOrCreateCounter(Metric{Name: "bad_counter"})
+ assert.ErrorContains(t, err, "invalid type")
+
+ f.gauges.Store("bad_gauge", "wrong")
+ _, err = f.getOrCreateGauge(Metric{Name: "bad_gauge"})
+ assert.ErrorContains(t, err, "invalid type")
+
+ f.histograms.Store("bad_histogram", "wrong")
+ _, err = f.getOrCreateHistogram(Metric{Name: "bad_histogram"})
+ assert.ErrorContains(t, err, "invalid type")
+}
+
+func TestMetricHelpersAndOptions(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, DefaultLatencyBuckets, selectDefaultBuckets("http_latency_seconds"))
+ assert.Equal(t, DefaultLatencyBuckets, selectDefaultBuckets("job_duration_seconds"))
+ assert.Equal(t, DefaultLatencyBuckets, selectDefaultBuckets("queue_time_seconds"))
+ assert.Equal(t, DefaultAccountBuckets, selectDefaultBuckets("account_created"))
+ assert.Equal(t, DefaultTransactionBuckets, selectDefaultBuckets("transaction_processed"))
+ assert.Equal(t, DefaultLatencyBuckets, selectDefaultBuckets("unknown_metric"))
+
+ assert.Equal(t, "hist:1,2,5", histogramCacheKey("hist", []float64{5, 1, 2}))
+ assert.Equal(t, "hist", histogramCacheKey("hist", nil))
+
+ f := newTestFactory(t)
+ assert.Len(t, f.addCounterOptions(Metric{Description: "d", Unit: "1"}), 2)
+ assert.Len(t, f.addGaugeOptions(Metric{Description: "d", Unit: "1"}), 2)
+ assert.Len(t, f.addHistogramOptions(Metric{Description: "d", Unit: "ms", Buckets: []float64{1}}), 3)
+}
+
+func TestRecordSystemUsageValidatesRange(t *testing.T) {
+ t.Parallel()
+
+ f := newTestFactory(t)
+ ctx := context.Background()
+
+ for _, err := range []error{
+ f.RecordSystemCPUUsage(ctx, -1),
+ f.RecordSystemCPUUsage(ctx, 101),
+ f.RecordSystemMemUsage(ctx, -1),
+ f.RecordSystemMemUsage(ctx, 101),
+ } {
+ assert.True(t, errors.Is(err, ErrPercentageOutOfRange))
+ }
+}
diff --git a/metrics/operation_routes.go b/metrics/operation_routes.go
new file mode 100644
index 0000000..e9ea4ce
--- /dev/null
+++ b/metrics/operation_routes.go
@@ -0,0 +1,21 @@
+package metrics
+
+import (
+ "context"
+
+ "go.opentelemetry.io/otel/attribute"
+)
+
+// RecordOperationRouteCreated increments the operation-route-created counter.
+func (f *MetricsFactory) RecordOperationRouteCreated(ctx context.Context, attributes ...attribute.KeyValue) error {
+ if f == nil {
+ return ErrNilFactory
+ }
+
+ b, err := f.Counter(MetricOperationRoutesCreated)
+ if err != nil {
+ return err
+ }
+
+ return b.WithAttributes(attributes...).AddOne(ctx)
+}
diff --git a/metrics/system.go b/metrics/system.go
new file mode 100644
index 0000000..8b554b7
--- /dev/null
+++ b/metrics/system.go
@@ -0,0 +1,60 @@
+package metrics
+
+import (
+ "context"
+)
+
+// Pre-configured system metrics for infrastructure monitoring.
+var (
+ // MetricSystemCPUUsage is a gauge that records the current CPU usage percentage.
+ MetricSystemCPUUsage = Metric{
+ Name: "system.cpu.usage",
+ Unit: "percentage",
+ Description: "Current CPU usage percentage of the process host.",
+ }
+
+ // MetricSystemMemUsage is a gauge that records the current memory usage percentage.
+ MetricSystemMemUsage = Metric{
+ Name: "system.mem.usage",
+ Unit: "percentage",
+ Description: "Current memory usage percentage of the process host.",
+ }
+)
+
+// RecordSystemCPUUsage records the current CPU usage percentage via the factory's gauge.
+// The percentage must be in the range [0, 100].
+func (f *MetricsFactory) RecordSystemCPUUsage(ctx context.Context, percentage int64) error {
+ if f == nil {
+ return ErrNilFactory
+ }
+
+ if percentage < 0 || percentage > 100 {
+ return ErrPercentageOutOfRange
+ }
+
+ b, err := f.Gauge(MetricSystemCPUUsage)
+ if err != nil {
+ return err
+ }
+
+ return b.Set(ctx, percentage)
+}
+
+// RecordSystemMemUsage records the current memory usage percentage via the factory's gauge.
+// The percentage must be in the range [0, 100].
+func (f *MetricsFactory) RecordSystemMemUsage(ctx context.Context, percentage int64) error {
+ if f == nil {
+ return ErrNilFactory
+ }
+
+ if percentage < 0 || percentage > 100 {
+ return ErrPercentageOutOfRange
+ }
+
+ b, err := f.Gauge(MetricSystemMemUsage)
+ if err != nil {
+ return err
+ }
+
+ return b.Set(ctx, percentage)
+}
diff --git a/metrics/transaction.go b/metrics/transaction.go
new file mode 100644
index 0000000..a238238
--- /dev/null
+++ b/metrics/transaction.go
@@ -0,0 +1,21 @@
+package metrics
+
+import (
+ "context"
+
+ "go.opentelemetry.io/otel/attribute"
+)
+
+// RecordTransactionProcessed increments the transaction-processed counter.
+func (f *MetricsFactory) RecordTransactionProcessed(ctx context.Context, attributes ...attribute.KeyValue) error {
+ if f == nil {
+ return ErrNilFactory
+ }
+
+ b, err := f.Counter(MetricTransactionsProcessed)
+ if err != nil {
+ return err
+ }
+
+ return b.WithAttributes(attributes...).AddOne(ctx)
+}
diff --git a/metrics/transaction_routes.go b/metrics/transaction_routes.go
new file mode 100644
index 0000000..bb2f331
--- /dev/null
+++ b/metrics/transaction_routes.go
@@ -0,0 +1,21 @@
+package metrics
+
+import (
+ "context"
+
+ "go.opentelemetry.io/otel/attribute"
+)
+
+// RecordTransactionRouteCreated increments the transaction-route-created counter.
+func (f *MetricsFactory) RecordTransactionRouteCreated(ctx context.Context, attributes ...attribute.KeyValue) error {
+ if f == nil {
+ return ErrNilFactory
+ }
+
+ b, err := f.Counter(MetricTransactionRoutesCreated)
+ if err != nil {
+ return err
+ }
+
+ return b.WithAttributes(attributes...).AddOne(ctx)
+}
diff --git a/middleware/context_span.go b/middleware/context_span.go
new file mode 100644
index 0000000..356cd41
--- /dev/null
+++ b/middleware/context_span.go
@@ -0,0 +1,73 @@
+package middleware
+
+import (
+ "reflect"
+
+ "github.com/google/uuid"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/trace"
+)
+
+// isNilSpan reports whether span is nil, including typed-nil interface values
+// where a concrete nil pointer is stored in a trace.Span interface.
+// This prevents panics when calling methods on a typed-nil span.
+func isNilSpan(span trace.Span) bool {
+ if span == nil {
+ return true
+ }
+
+ v := reflect.ValueOf(span)
+
+ switch v.Kind() {
+ case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
+ return v.IsNil()
+ default:
+ return false
+ }
+}
+
+// SetHandlerSpanAttributes adds tenant_id and context_id attributes to a trace span.
+func SetHandlerSpanAttributes(span trace.Span, tenantID, contextID uuid.UUID) {
+ if isNilSpan(span) {
+ return
+ }
+
+ span.SetAttributes(attribute.String("tenant.id", tenantID.String()))
+
+ if contextID != uuid.Nil {
+ span.SetAttributes(attribute.String("context.id", contextID.String()))
+ }
+}
+
+// SetTenantSpanAttribute adds tenant_id attribute to a trace span.
+func SetTenantSpanAttribute(span trace.Span, tenantID uuid.UUID) {
+ if isNilSpan(span) {
+ return
+ }
+
+ span.SetAttributes(attribute.String("tenant.id", tenantID.String()))
+}
+
+// SetExceptionSpanAttributes adds tenant_id and exception_id attributes to a trace span.
+func SetExceptionSpanAttributes(span trace.Span, tenantID, exceptionID uuid.UUID) {
+ if isNilSpan(span) {
+ return
+ }
+
+ span.SetAttributes(
+ attribute.String("tenant.id", tenantID.String()),
+ attribute.String("exception.id", exceptionID.String()),
+ )
+}
+
+// SetDisputeSpanAttributes adds tenant_id and dispute_id attributes to a trace span.
+func SetDisputeSpanAttributes(span trace.Span, tenantID, disputeID uuid.UUID) {
+ if isNilSpan(span) {
+ return
+ }
+
+ span.SetAttributes(
+ attribute.String("tenant.id", tenantID.String()),
+ attribute.String("dispute.id", disputeID.String()),
+ )
+}
diff --git a/middleware/helpers.go b/middleware/helpers.go
new file mode 100644
index 0000000..c356ba3
--- /dev/null
+++ b/middleware/helpers.go
@@ -0,0 +1,150 @@
+package middleware
+
+import (
+ "context"
+ "net/url"
+ "regexp"
+ "strings"
+
+ constant "github.com/LerianStudio/lib-observability/constants"
+ "github.com/LerianStudio/lib-observability/redaction"
+ "github.com/gofiber/fiber/v2"
+ "google.golang.org/grpc/metadata"
+)
+
+// uuidPattern matches standard UUID v4 strings (8-4-4-4-12 hex digits).
+var uuidPattern = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`)
+
+// internalServicePattern matches Lerian internal service user-agent strings.
+var internalServicePattern = regexp.MustCompile(`^[\w-]+/[\d.]+\s+LerianStudio$`)
+
+// isRouteExcludedFromList reports whether the request path matches any excluded route prefix.
+// This standalone function is used to evaluate route exclusions independently of whether
+// the TelemetryMiddleware receiver is nil.
+func isRouteExcludedFromList(c *fiber.Ctx, excludedRoutes []string) bool {
+ for _, route := range excludedRoutes {
+ if strings.HasPrefix(c.Path(), route) {
+ return true
+ }
+ }
+
+ return false
+}
+
+// sanitizeURL removes or obfuscates sensitive query parameters from URLs
+// to prevent exposing tokens, API keys, and other sensitive data in telemetry.
+func sanitizeURL(rawURL string) string {
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ return sanitizeMalformedURL(rawURL)
+ }
+
+ if parsed.RawQuery == "" {
+ return rawURL
+ }
+
+ query := parsed.Query()
+ modified := false
+
+ for key := range query {
+ if redaction.IsSensitiveField(key) {
+ query.Set(key, constant.ObfuscatedValue)
+
+ modified = true
+ }
+ }
+
+ if !modified {
+ return rawURL
+ }
+
+ parsed.RawQuery = query.Encode()
+
+ return parsed.String()
+}
+
+func sanitizeMalformedURL(rawURL string) string {
+ sanitized := sanitizeLogValue(rawURL)
+ if before, _, ok := strings.Cut(sanitized, "?"); ok {
+ return before + "?redacted"
+ }
+
+ return sanitized
+}
+
+// sanitizeLogValue removes control characters (newlines, carriage returns, null bytes)
+// from a string to prevent log injection attacks (CWE-117).
+func sanitizeLogValue(raw string) string {
+ replacer := strings.NewReplacer("\r", "", "\n", "", "\x00", "")
+ return replacer.Replace(raw)
+}
+
+// getGRPCUserAgent extracts the User-Agent from incoming gRPC metadata.
+// Returns empty string if the metadata is not present or doesn't contain user-agent.
+func getGRPCUserAgent(ctx context.Context) string {
+ if ctx == nil {
+ return ""
+ }
+
+ md, ok := metadata.FromIncomingContext(ctx)
+ if !ok || md == nil {
+ return ""
+ }
+
+ userAgents := md.Get(strings.ToLower(headerUserAgent))
+ if len(userAgents) == 0 {
+ return ""
+ }
+
+ return userAgents[0]
+}
+
+// isInternalLerianService reports whether a user-agent belongs to a Lerian internal service.
+func isInternalLerianService(userAgent string) bool {
+ return internalServicePattern.MatchString(userAgent)
+}
+
+// replaceUUIDWithPlaceholder replaces UUIDs with a placeholder in a given path string.
+func replaceUUIDWithPlaceholder(path string) string {
+ return uuidPattern.ReplaceAllString(path, ":id")
+}
+
+// isNilOrEmptyString reports whether a string pointer is nil or the trimmed value is empty.
+// "null" and "nil" are treated as empty to handle JSON null serialization artifacts
+// where some encoders emit the literal string "null" or "nil" instead of a JSON null.
+func isNilOrEmptyString(s *string) bool {
+ return s == nil || strings.TrimSpace(*s) == "" || strings.TrimSpace(*s) == "null" || strings.TrimSpace(*s) == "nil"
+}
+
+// normalizeRequestID trims whitespace and control characters from a raw request ID.
+func normalizeRequestID(raw string) string {
+ return strings.TrimSpace(sanitizeLogValue(raw))
+}
+
+// normalizeGRPCContext returns a non-nil context, falling back to context.Background().
+func normalizeGRPCContext(ctx context.Context) context.Context {
+ if ctx == nil {
+ return context.Background()
+ }
+
+ return ctx
+}
+
+// getMetadataID extracts a correlation id from incoming gRPC metadata if present.
+func getMetadataID(ctx context.Context) string {
+ if ctx == nil {
+ return ""
+ }
+
+ if md, ok := metadata.FromIncomingContext(ctx); ok && md != nil {
+ headerID := md.Get(metadataID)
+ if len(headerID) > 0 {
+ v := normalizeRequestID(headerID[0])
+ if v != "" && v != "null" && v != "nil" {
+ return v
+ }
+ }
+ }
+
+ return ""
+}
diff --git a/middleware/logging.go b/middleware/logging.go
new file mode 100644
index 0000000..b9e8179
--- /dev/null
+++ b/middleware/logging.go
@@ -0,0 +1,317 @@
+package middleware
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/url"
+ "os"
+ "reflect"
+ "strconv"
+ "strings"
+ "time"
+
+ observability "github.com/LerianStudio/lib-observability"
+ constant "github.com/LerianStudio/lib-observability/constants"
+ obslog "github.com/LerianStudio/lib-observability/log"
+ "github.com/gofiber/fiber/v2"
+ "go.opentelemetry.io/otel/attribute"
+ "google.golang.org/grpc"
+)
+
+const (
+ headerReferer = "Referer"
+ headerContentType = "Content-Type"
+
+ maxObfuscationDepth = 32
+)
+
+var logObfuscationDisabled = os.Getenv("LOG_OBFUSCATION_DISABLED") == "true"
+
+// RequestInfo stores HTTP access log data.
+type RequestInfo struct {
+ Method string
+ Username string
+ URI string
+ Referer string
+ RemoteAddress string
+ Status int
+ Date time.Time
+ Duration time.Duration
+ UserAgent string
+ TraceID string
+ Protocol string
+ Size int
+ Body string
+}
+
+// ResponseMetricsWrapper stores response metadata used to finish RequestInfo.
+type ResponseMetricsWrapper struct {
+ Context *fiber.Ctx
+ StatusCode int
+ Size int
+}
+
+type logMiddleware struct {
+ Logger obslog.Logger
+ ObfuscationDisabled bool
+}
+
+// LogMiddlewareOption configures HTTP and gRPC logging middleware.
+type LogMiddlewareOption func(l *logMiddleware)
+
+// WithCustomLogger configures a custom logger for access logging.
+func WithCustomLogger(logger obslog.Logger) LogMiddlewareOption {
+ return func(l *logMiddleware) {
+ if !isNilLogger(logger) {
+ l.Logger = logger
+ }
+ }
+}
+
+// WithObfuscationDisabled disables request body obfuscation.
+func WithObfuscationDisabled(disabled bool) LogMiddlewareOption {
+ return func(l *logMiddleware) {
+ l.ObfuscationDisabled = disabled
+ }
+}
+
+func buildOpts(opts ...LogMiddlewareOption) *logMiddleware {
+ mid := &logMiddleware{
+ Logger: &obslog.GoLogger{},
+ ObfuscationDisabled: logObfuscationDisabled,
+ }
+
+ for _, opt := range opts {
+ opt(mid)
+ }
+
+ return mid
+}
+
+func isNilLogger(logger obslog.Logger) bool {
+ if logger == nil {
+ return true
+ }
+
+ value := reflect.ValueOf(logger)
+ switch value.Kind() {
+ case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
+ return value.IsNil()
+ default:
+ return false
+ }
+}
+
+// NewRequestInfo creates RequestInfo from a Fiber context.
+func NewRequestInfo(c *fiber.Ctx, obfuscationDisabled bool) *RequestInfo {
+ if c == nil {
+ return &RequestInfo{Date: time.Now().UTC()}
+ }
+
+ username, referer := "-", "-"
+ rawURL := string(c.Request().URI().FullURI())
+
+ parsedURL, err := url.Parse(rawURL)
+ if err == nil && parsedURL.User != nil {
+ if name := parsedURL.User.Username(); name != "" {
+ username = name
+ }
+ }
+
+ if c.Get(headerReferer) != "" {
+ referer = sanitizeReferer(c.Get(headerReferer))
+ }
+
+ body := ""
+ bodyBytes := c.Body()
+
+ if len(bodyBytes) > 0 {
+ if !obfuscationDisabled {
+ body = getBodyObfuscatedString(c, bodyBytes)
+ } else {
+ body = string(bodyBytes)
+ }
+ }
+
+ return &RequestInfo{
+ TraceID: c.Get(headerID),
+ Method: c.Method(),
+ URI: sanitizeURL(c.OriginalURL()),
+ Username: username,
+ Referer: referer,
+ UserAgent: sanitizeLogValue(c.Get(headerUserAgent)),
+ RemoteAddress: c.IP(),
+ Protocol: c.Protocol(),
+ Date: time.Now().UTC(),
+ Body: body,
+ }
+}
+
+// CLFString returns a Common Log Format style access log entry.
+func (r *RequestInfo) CLFString() string {
+ return strings.Join([]string{
+ sanitizeLogValue(r.RemoteAddress),
+ "-",
+ sanitizeLogValue(r.Username),
+ sanitizeLogValue(r.Protocol),
+ r.Date.Format("[02/Jan/2006:15:04:05 -0700]"),
+ `"` + sanitizeLogValue(r.Method) + " " + sanitizeLogValue(r.URI) + `"`,
+ strconv.Itoa(r.Status),
+ strconv.Itoa(r.Size),
+ sanitizeLogValue(r.Referer),
+ sanitizeLogValue(r.UserAgent),
+ }, " ")
+}
+
+func (r *RequestInfo) String() string {
+ return r.CLFString()
+}
+
+// FinishRequestInfo fills response status, body size, and duration.
+func (r *RequestInfo) FinishRequestInfo(rw *ResponseMetricsWrapper) {
+ if rw == nil {
+ return
+ }
+
+ r.Duration = time.Since(r.Date)
+ r.Status = rw.StatusCode
+ r.Size = rw.Size
+}
+
+// WithHTTPLogging logs Fiber HTTP access requests.
+func WithHTTPLogging(opts ...LogMiddlewareOption) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ if c.Path() == "/health" {
+ return c.Next()
+ }
+
+ if strings.Contains(c.Path(), "swagger") && c.Path() != "/swagger/index.html" {
+ return c.Next()
+ }
+
+ setRequestHeaderID(c)
+
+ mid := buildOpts(opts...)
+ info := NewRequestInfo(c, mid.ObfuscationDisabled)
+
+ requestID := c.Get(headerID)
+ logger := mid.Logger.
+ With(obslog.String(headerID, info.TraceID)).
+ With(obslog.String("message_prefix", requestID+constant.LoggerDefaultSeparator))
+
+ ctx := observability.ContextWithLogger(c.UserContext(), logger)
+ c.SetUserContext(ctx)
+
+ err := c.Next()
+ statusCode := httpStatusCode(c, err)
+
+ rw := ResponseMetricsWrapper{
+ Context: c,
+ StatusCode: statusCode,
+ Size: len(c.Response().Body()),
+ }
+
+ info.FinishRequestInfo(&rw)
+ logger.Log(c.UserContext(), obslog.LevelInfo, info.CLFString())
+
+ return err
+ }
+}
+
+func httpStatusCode(c *fiber.Ctx, err error) int {
+ statusCode := c.Response().StatusCode()
+ if err == nil {
+ return statusCode
+ }
+
+ var fiberErr *fiber.Error
+ if errors.As(err, &fiberErr) {
+ return fiberErr.Code
+ }
+
+ if statusCode < fiber.StatusBadRequest {
+ return fiber.StatusInternalServerError
+ }
+
+ return statusCode
+}
+
+// WithGrpcLogging logs gRPC unary requests and attaches a request-scoped logger to context.
+func WithGrpcLogging(opts ...LogMiddlewareOption) grpc.UnaryServerInterceptor {
+ return func(
+ ctx context.Context,
+ req any,
+ info *grpc.UnaryServerInfo,
+ handler grpc.UnaryHandler,
+ ) (any, error) {
+ ctx = normalizeGRPCContext(ctx)
+ requestID := resolveGRPCRequestID(ctx, req)
+
+ if rid, ok := getValidBodyRequestID(req); ok {
+ if prev := getMetadataID(ctx); prev != "" && prev != rid {
+ mid := buildOpts(opts...)
+ mid.Logger.Log(ctx, obslog.LevelDebug, "Overriding correlation id from metadata with body request_id",
+ obslog.String("metadata_id", prev),
+ obslog.String("body_request_id", rid),
+ )
+ }
+ }
+
+ ctx = observability.ContextWithHeaderID(ctx, requestID)
+ ctx = observability.ContextWithSpanAttributes(ctx, attribute.String("app.request.request_id", requestID))
+
+ _, _, reqID, _ := observability.NewTrackingFromContext(ctx)
+
+ mid := buildOpts(opts...)
+ logger := mid.Logger.
+ With(obslog.String(headerID, reqID)).
+ With(obslog.String("message_prefix", reqID+constant.LoggerDefaultSeparator))
+
+ ctx = observability.ContextWithLogger(ctx, logger)
+
+ start := time.Now()
+ resp, err := handler(ctx, req)
+ duration := time.Since(start)
+
+ methodName := "unknown"
+ if info != nil {
+ methodName = info.FullMethod
+ }
+
+ fields := []obslog.Field{
+ obslog.String("method", methodName),
+ obslog.String("duration", duration.String()),
+ }
+ if err != nil {
+ fields = append(fields, obslog.Err(err))
+ }
+
+ logger.Log(ctx, obslog.LevelInfo, "gRPC request finished", fields...)
+
+ return resp, err
+ }
+}
+
+func handleJSONBody(bodyBytes []byte) string {
+ var bodyData any
+ if err := json.Unmarshal(bodyBytes, &bodyData); err != nil {
+ return redactedBody
+ }
+
+ switch v := bodyData.(type) {
+ case map[string]any:
+ obfuscateMapRecursively(v, 0)
+ case []any:
+ obfuscateSliceRecursively(v, 0)
+ default:
+ return redactedBody
+ }
+
+ updatedBody, err := json.Marshal(bodyData)
+ if err != nil {
+ return redactedBody
+ }
+
+ return string(updatedBody)
+}
diff --git a/middleware/logging_obfuscation.go b/middleware/logging_obfuscation.go
new file mode 100644
index 0000000..5095190
--- /dev/null
+++ b/middleware/logging_obfuscation.go
@@ -0,0 +1,133 @@
+package middleware
+
+import (
+ "net/url"
+ "strings"
+
+ constant "github.com/LerianStudio/lib-observability/constants"
+ "github.com/LerianStudio/lib-observability/redaction"
+ "github.com/gofiber/fiber/v2"
+)
+
+const redactedBody = "[REDACTED]"
+
+func getBodyObfuscatedString(c *fiber.Ctx, bodyBytes []byte) string {
+ contentType := strings.ToLower(c.Get(headerContentType))
+
+ switch {
+ case strings.Contains(contentType, "application/json"):
+ return handleJSONBody(bodyBytes)
+ case strings.Contains(contentType, "application/x-www-form-urlencoded"):
+ return handleURLEncodedBody(bodyBytes)
+ case strings.Contains(contentType, "multipart/form-data"):
+ return handleMultipartBody(c)
+ default:
+ return redactedBody
+ }
+}
+
+func obfuscateMapRecursively(data map[string]any, depth int) {
+ if depth >= maxObfuscationDepth {
+ return
+ }
+
+ for key, value := range data {
+ if redaction.IsSensitiveField(key) {
+ data[key] = constant.ObfuscatedValue
+ continue
+ }
+
+ switch v := value.(type) {
+ case map[string]any:
+ obfuscateMapRecursively(v, depth+1)
+ case []any:
+ obfuscateSliceRecursively(v, depth+1)
+ }
+ }
+}
+
+func obfuscateSliceRecursively(data []any, depth int) {
+ if depth >= maxObfuscationDepth {
+ return
+ }
+
+ for _, item := range data {
+ switch v := item.(type) {
+ case map[string]any:
+ obfuscateMapRecursively(v, depth+1)
+ case []any:
+ obfuscateSliceRecursively(v, depth+1)
+ }
+ }
+}
+
+func handleURLEncodedBody(bodyBytes []byte) string {
+ formData, err := url.ParseQuery(string(bodyBytes))
+ if err != nil {
+ return redactedBody
+ }
+
+ updatedBody := url.Values{}
+
+ for key, values := range formData {
+ if redaction.IsSensitiveField(key) {
+ for range values {
+ updatedBody.Add(key, constant.ObfuscatedValue)
+ }
+
+ continue
+ }
+
+ for _, value := range values {
+ updatedBody.Add(key, value)
+ }
+ }
+
+ return updatedBody.Encode()
+}
+
+func handleMultipartBody(c *fiber.Ctx) string {
+ form, err := c.MultipartForm()
+ if err != nil {
+ return "[multipart/form-data]"
+ }
+
+ result := url.Values{}
+
+ for key, values := range form.Value {
+ if redaction.IsSensitiveField(key) {
+ for range values {
+ result.Add(key, constant.ObfuscatedValue)
+ }
+
+ continue
+ }
+
+ for _, value := range values {
+ result.Add(key, value)
+ }
+ }
+
+ for key := range form.File {
+ if redaction.IsSensitiveField(key) {
+ result.Add(key, constant.ObfuscatedValue)
+ } else {
+ result.Add(key, "[file]")
+ }
+ }
+
+ return result.Encode()
+}
+
+func sanitizeReferer(raw string) string {
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ return "-"
+ }
+
+ parsed.User = nil
+ parsed.RawQuery = ""
+ parsed.Fragment = ""
+
+ return parsed.String()
+}
diff --git a/middleware/logging_test.go b/middleware/logging_test.go
new file mode 100644
index 0000000..7df247f
--- /dev/null
+++ b/middleware/logging_test.go
@@ -0,0 +1,212 @@
+//go:build unit
+
+package middleware
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+
+ observability "github.com/LerianStudio/lib-observability"
+ obslog "github.com/LerianStudio/lib-observability/log"
+ "github.com/gofiber/fiber/v2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel/attribute"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/metadata"
+)
+
+type captureLogger struct {
+ mu sync.Mutex
+ messages []string
+ fields []obslog.Field
+}
+
+func (l *captureLogger) Log(_ context.Context, _ obslog.Level, msg string, fields ...obslog.Field) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ l.messages = append(l.messages, msg)
+ l.fields = append(l.fields, fields...)
+}
+
+func (l *captureLogger) With(fields ...obslog.Field) obslog.Logger {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ l.fields = append(l.fields, fields...)
+
+ return l
+}
+
+func (l *captureLogger) WithGroup(_ string) obslog.Logger { return l }
+
+func (l *captureLogger) Enabled(_ obslog.Level) bool { return true }
+
+func (l *captureLogger) Sync(_ context.Context) error { return nil }
+
+func (l *captureLogger) snapshot() ([]string, []obslog.Field) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ messages := append([]string(nil), l.messages...)
+ fields := append([]obslog.Field(nil), l.fields...)
+
+ return messages, fields
+}
+
+type grpcRequestWithID struct {
+ RequestId string
+}
+
+func (r grpcRequestWithID) GetRequestId() string {
+ return r.RequestId
+}
+
+func TestNewRequestInfoSanitizesRequestData(t *testing.T) {
+ app := fiber.New()
+ app.Post("/charge", func(c *fiber.Ctx) error {
+ info := NewRequestInfo(c, false)
+
+ assert.Equal(t, "POST", info.Method)
+ assert.Equal(t, "/charge?name=alice&password=%2A%2A%2A%2A%2A%2A%2A%2A", info.URI)
+ assert.Equal(t, "https://example.com/path", info.Referer)
+ assert.Equal(t, "agent", info.UserAgent)
+ assert.JSONEq(t, "{\"nested\":{\"secret\":\"********\"},\"password\":\"********\"}", info.Body)
+
+ return c.SendStatus(http.StatusNoContent)
+ })
+
+ req := httptest.NewRequest(http.MethodPost, "/charge?name=alice&password=secret", strings.NewReader("{\"password\":\"secret\",\"nested\":{\"secret\":\"value\"}}"))
+ req.Header.Set(headerContentType, "Application/JSON")
+ req.Header.Set(headerReferer, "https://user:pass@example.com/path?token=secret#fragment")
+ req.Header.Set(headerUserAgent, "agent")
+
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, resp.Body.Close()) }()
+ assert.Equal(t, http.StatusNoContent, resp.StatusCode)
+}
+
+func TestWithHTTPLoggingAttachesLoggerAndLogsAccessEntry(t *testing.T) {
+ logger := &captureLogger{}
+ app := fiber.New()
+ app.Use(WithHTTPLogging(WithCustomLogger(logger)))
+ app.Get("/ok", func(c *fiber.Ctx) error {
+ _, _, requestID, _ := observability.NewTrackingFromContext(c.UserContext())
+ assert.NotEmpty(t, requestID)
+ assert.Same(t, logger, observability.NewLoggerFromContext(c.UserContext()))
+
+ return c.SendString("ok")
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/ok", nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, resp.Body.Close()) }()
+
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ assert.Equal(t, "ok", string(body))
+ assert.NotEmpty(t, resp.Header.Get(headerID))
+
+ messages, fields := logger.snapshot()
+ require.Len(t, messages, 1)
+ assert.Contains(t, messages[0], "GET /ok")
+ assert.Contains(t, fields, obslog.String(headerID, resp.Header.Get(headerID)))
+}
+
+func TestWithHTTPLoggingIgnoresTypedNilLogger(t *testing.T) {
+ var logger *captureLogger
+ app := fiber.New()
+ app.Use(WithHTTPLogging(WithCustomLogger(logger)))
+ app.Get("/ok", func(c *fiber.Ctx) error {
+ assert.NotNil(t, observability.NewLoggerFromContext(c.UserContext()))
+ return c.SendStatus(http.StatusNoContent)
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/ok", nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, resp.Body.Close()) }()
+ assert.Equal(t, http.StatusNoContent, resp.StatusCode)
+}
+
+func TestWithHTTPLoggingLogsErrorStatus(t *testing.T) {
+ logger := &captureLogger{}
+ app := fiber.New()
+ app.Use(WithHTTPLogging(WithCustomLogger(logger)))
+ app.Get("/fail", func(*fiber.Ctx) error {
+ return errors.New("handler failed")
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/fail", nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, resp.Body.Close()) }()
+
+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
+
+ messages, _ := logger.snapshot()
+ require.Len(t, messages, 1)
+ assert.Contains(t, messages[0], "GET /fail")
+ assert.Contains(t, messages[0], " 500 ")
+}
+
+func TestWithGrpcLoggingUsesBodyRequestIDAndLogsResult(t *testing.T) {
+ logger := &captureLogger{}
+ bodyRequestID := "4fbf011b-bb11-4c73-9f7c-4f8e19ca8402"
+ metadataRequestID := "7cb344b8-b5ec-4bf7-a2a1-0320763bdc67"
+ ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs(metadataID, metadataRequestID))
+
+ interceptor := WithGrpcLogging(WithCustomLogger(logger))
+ resp, err := interceptor(
+ ctx,
+ grpcRequestWithID{RequestId: bodyRequestID},
+ &grpc.UnaryServerInfo{FullMethod: "/service.Method"},
+ func(ctx context.Context, _ any) (any, error) {
+ _, _, requestID, _ := observability.NewTrackingFromContext(ctx)
+ assert.Equal(t, bodyRequestID, requestID)
+ assert.Same(t, logger, observability.NewLoggerFromContext(ctx))
+ assert.Contains(t, observability.AttributesFromContext(ctx), attribute.String("app.request.request_id", bodyRequestID))
+
+ return "ok", nil
+ },
+ )
+ require.NoError(t, err)
+ assert.Equal(t, "ok", resp)
+
+ messages, fields := logger.snapshot()
+ assert.Contains(t, messages, "Overriding correlation id from metadata with body request_id")
+ assert.Contains(t, messages, "gRPC request finished")
+ assert.Contains(t, fields, obslog.String(headerID, bodyRequestID))
+}
+
+func TestWithGrpcLoggingLogsHandlerError(t *testing.T) {
+ logger := &captureLogger{}
+ expectedErr := errors.New("handler failed")
+
+ interceptor := WithGrpcLogging(WithCustomLogger(logger))
+ resp, err := interceptor(
+ context.Background(),
+ grpcRequestWithID{RequestId: "4fbf011b-bb11-4c73-9f7c-4f8e19ca8402"},
+ &grpc.UnaryServerInfo{FullMethod: "/service.Method"},
+ func(context.Context, any) (any, error) {
+ return nil, expectedErr
+ },
+ )
+
+ assert.Nil(t, resp)
+ assert.ErrorIs(t, err, expectedErr)
+
+ _, fields := logger.snapshot()
+ assert.Contains(t, fields, obslog.Err(expectedErr))
+}
diff --git a/middleware/metrics.go b/middleware/metrics.go
new file mode 100644
index 0000000..a277092
--- /dev/null
+++ b/middleware/metrics.go
@@ -0,0 +1,136 @@
+package middleware
+
+import (
+ "context"
+ "errors"
+ "os"
+ "sync"
+ "time"
+
+ observability "github.com/LerianStudio/lib-observability"
+ "github.com/LerianStudio/lib-observability/runtime"
+)
+
+// DefaultMetricsCollectionInterval is the default interval for collecting system metrics.
+// Can be overridden via METRICS_COLLECTION_INTERVAL environment variable.
+const DefaultMetricsCollectionInterval = 5 * time.Second
+
+// Metrics collector singleton state.
+var (
+ metricsCollectorOnce = &sync.Once{}
+ metricsCollectorShutdown chan struct{}
+ metricsCollectorMu sync.Mutex
+ metricsCollectorStarted bool
+ metricsCollectorInitErr error
+)
+
+// telemetryRuntimeLogger returns the runtime logger from the telemetry middleware, or nil.
+func telemetryRuntimeLogger(tm *TelemetryMiddleware) runtime.Logger {
+ if tm == nil || tm.Telemetry == nil {
+ return nil
+ }
+
+ return tm.Telemetry.Logger
+}
+
+// collectMetrics ensures the background metrics collector goroutine is running.
+func (tm *TelemetryMiddleware) collectMetrics(_ context.Context) error {
+ return tm.ensureMetricsCollector()
+}
+
+// getMetricsCollectionInterval returns the metrics collection interval.
+// Can be configured via METRICS_COLLECTION_INTERVAL environment variable.
+// Accepts Go duration format (e.g., "10s", "1m", "500ms").
+// Falls back to DefaultMetricsCollectionInterval if not set or invalid.
+func getMetricsCollectionInterval() time.Duration {
+ if envInterval := os.Getenv("METRICS_COLLECTION_INTERVAL"); envInterval != "" {
+ if parsed, err := time.ParseDuration(envInterval); err == nil && parsed > 0 {
+ return parsed
+ }
+ }
+
+ return DefaultMetricsCollectionInterval
+}
+
+// ensureMetricsCollector lazily starts the background metrics collector singleton.
+func (tm *TelemetryMiddleware) ensureMetricsCollector() error {
+ if tm == nil || tm.Telemetry == nil {
+ return nil
+ }
+
+ if tm.Telemetry.MeterProvider == nil {
+ return nil
+ }
+
+ metricsCollectorMu.Lock()
+ defer metricsCollectorMu.Unlock()
+
+ if metricsCollectorStarted {
+ return nil
+ }
+
+ if metricsCollectorInitErr != nil {
+ metricsCollectorOnce = &sync.Once{}
+ metricsCollectorInitErr = nil
+ }
+
+ metricsCollectorOnce.Do(func() {
+ factory := tm.Telemetry.MetricsFactory
+ if factory == nil {
+ metricsCollectorInitErr = errors.New("telemetry MetricsFactory is nil, cannot start system metrics collector")
+ return
+ }
+
+ shutdown := make(chan struct{})
+ metricsCollectorShutdown = shutdown
+ ticker := time.NewTicker(getMetricsCollectionInterval())
+
+ runtime.SafeGoWithContextAndComponent(
+ context.Background(),
+ telemetryRuntimeLogger(tm),
+ "http",
+ "metrics_collector",
+ runtime.KeepRunning,
+ func(_ context.Context) {
+ observability.GetCPUUsage(context.Background(), factory)
+ observability.GetMemUsage(context.Background(), factory)
+
+ for {
+ select {
+ case <-shutdown:
+ ticker.Stop()
+ return
+ case <-ticker.C:
+ observability.GetCPUUsage(context.Background(), factory)
+ observability.GetMemUsage(context.Background(), factory)
+ }
+ }
+ },
+ )
+
+ metricsCollectorStarted = true
+ })
+
+ return metricsCollectorInitErr
+}
+
+// StopMetricsCollector stops the background metrics collector goroutine.
+// Should be called during application shutdown for graceful cleanup.
+// After calling this function, the collector can be restarted by new requests.
+//
+// Implementation note: This function intentionally resets sync.Once to a new instance
+// to allow the collector to be restarted after being stopped. This is an unusual but
+// intentional pattern - the mutex ensures thread-safety during the reset operation,
+// preventing race conditions between Stop and subsequent Start calls.
+func StopMetricsCollector() {
+ metricsCollectorMu.Lock()
+ defer metricsCollectorMu.Unlock()
+
+ if metricsCollectorStarted && metricsCollectorShutdown != nil {
+ close(metricsCollectorShutdown)
+
+ metricsCollectorStarted = false
+ metricsCollectorOnce = &sync.Once{}
+ metricsCollectorInitErr = nil
+ }
+}
diff --git a/middleware/telemetry.go b/middleware/telemetry.go
new file mode 100644
index 0000000..9d47fe3
--- /dev/null
+++ b/middleware/telemetry.go
@@ -0,0 +1,377 @@
+// Package middleware provides Fiber HTTP and gRPC telemetry middleware that
+// integrates with the lib-observability tracing and metrics packages.
+package middleware
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "reflect"
+ "strings"
+ "sync"
+
+ observability "github.com/LerianStudio/lib-observability"
+ "github.com/LerianStudio/lib-observability/tracing"
+ "github.com/gofiber/fiber/v2"
+ "github.com/google/uuid"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/codes"
+ "go.opentelemetry.io/otel/trace"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/status"
+)
+
+// Header and metadata key constants used by the middleware.
+const (
+ // headerID is the request identifier header key.
+ headerID = "X-Request-Id"
+ // headerUserAgent is the HTTP User-Agent header key.
+ headerUserAgent = "User-Agent"
+ // metadataID is the gRPC metadata key that carries the request context identifier.
+ metadataID = "metadata_id"
+)
+
+// ErrContextNotFound is returned when a required Fiber context is nil.
+var ErrContextNotFound = errors.New("fiber context not found")
+
+type spanEndStateKey struct{}
+
+type spanEndState struct {
+ span trace.Span
+ once sync.Once
+}
+
+func newSpanEndState(span trace.Span) *spanEndState {
+ return &spanEndState{span: span}
+}
+
+func (s *spanEndState) End() {
+ if s == nil || s.span == nil {
+ return
+ }
+
+ s.once.Do(func() { s.span.End() })
+}
+
+func contextWithSpanEndState(ctx context.Context, state *spanEndState) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ return context.WithValue(ctx, spanEndStateKey{}, state)
+}
+
+func spanEndStateFromContext(ctx context.Context) *spanEndState {
+ if ctx == nil {
+ return nil
+ }
+
+ state, _ := ctx.Value(spanEndStateKey{}).(*spanEndState)
+
+ return state
+}
+
+// TelemetryMiddleware wraps HTTP and gRPC handlers with tracing and metrics setup.
+type TelemetryMiddleware struct {
+ Telemetry *tracing.Telemetry
+}
+
+// NewTelemetryMiddleware creates a new instance of TelemetryMiddleware.
+func NewTelemetryMiddleware(tl *tracing.Telemetry) *TelemetryMiddleware {
+ return &TelemetryMiddleware{tl}
+}
+
+// WithTelemetry is a middleware that adds tracing to the context.
+func (tm *TelemetryMiddleware) WithTelemetry(tl *tracing.Telemetry, excludedRoutes ...string) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ effectiveTelemetry := tl
+ if effectiveTelemetry == nil && tm != nil {
+ effectiveTelemetry = tm.Telemetry
+ }
+
+ if effectiveTelemetry == nil {
+ return c.Next()
+ }
+
+ if len(excludedRoutes) > 0 && isRouteExcludedFromList(c, excludedRoutes) {
+ return c.Next()
+ }
+
+ setRequestHeaderID(c)
+
+ ctx := c.UserContext()
+ _, _, reqId, _ := observability.NewTrackingFromContext(ctx)
+
+ c.SetUserContext(observability.ContextWithSpanAttributes(ctx,
+ attribute.String("app.request.request_id", reqId),
+ ))
+
+ if effectiveTelemetry.TracerProvider == nil {
+ return c.Next()
+ }
+
+ // Capture all Fiber context string values BEFORE c.Next(). Fiber v2 uses
+ // utils.UnsafeString which returns pointers into fasthttp's request buffer.
+ // After c.Next() returns, fasthttp may recycle the underlying RequestCtx
+ // for the next connection, corrupting any previously returned string slices.
+ // Safe copies via string([]byte(...)) ensure the data is heap-owned.
+ method := string([]byte(c.Method()))
+ originalURL := string([]byte(c.OriginalURL()))
+ protocol := string([]byte(c.Protocol()))
+ hostname := string([]byte(c.Hostname()))
+ userAgent := string([]byte(c.Get(headerUserAgent)))
+
+ tracer := effectiveTelemetry.TracerProvider.Tracer(effectiveTelemetry.LibraryName)
+ routePathWithMethod := method + " " + replaceUUIDWithPlaceholder(c.Path())
+
+ traceCtx := c.UserContext()
+ // Compatibility note: trace extraction currently trusts the internal-service
+ // User-Agent heuristic. This is an interoperability hint, not an authenticated
+ // trust boundary, and is preserved to avoid changing existing caller behavior.
+ if isInternalLerianService(userAgent) {
+ traceCtx = tracing.ExtractHTTPContext(traceCtx, c)
+ }
+
+ ctx, span := tracer.Start(traceCtx, routePathWithMethod, trace.WithSpanKind(trace.SpanKindServer))
+ endState := newSpanEndState(span)
+
+ defer endState.End()
+
+ ctx = observability.ContextWithTracer(ctx, tracer)
+ ctx = observability.ContextWithMetricFactory(ctx, effectiveTelemetry.MetricsFactory)
+ ctx = contextWithSpanEndState(ctx, endState)
+ c.SetUserContext(ctx)
+
+ err := tm.collectMetrics(ctx)
+ if err != nil {
+ tracing.HandleSpanError(span, "Failed to collect metrics", err)
+ }
+
+ err = c.Next()
+
+ statusCode := c.Response().StatusCode()
+ span.SetAttributes(
+ attribute.String("http.request.method", method),
+ attribute.String("url.path", sanitizeURL(originalURL)),
+ attribute.String("http.route", c.Route().Path),
+ attribute.String("url.scheme", protocol),
+ attribute.String("server.address", hostname),
+ attribute.String("user_agent.original", userAgent),
+ attribute.Int("http.response.status_code", statusCode),
+ )
+
+ if err != nil {
+ tracing.HandleSpanError(span, "handler error", err)
+ } else if statusCode >= 500 {
+ span.SetStatus(codes.Error, fmt.Sprintf("HTTP %d", statusCode))
+ }
+
+ return err
+ }
+}
+
+// EndTracingSpans is a middleware that ends the tracing spans.
+func (tm *TelemetryMiddleware) EndTracingSpans(c *fiber.Ctx) error {
+ if c == nil {
+ return ErrContextNotFound
+ }
+
+ originalCtx := c.UserContext()
+ err := c.Next()
+
+ endCtx := c.UserContext()
+ if endCtx == nil {
+ endCtx = originalCtx
+ }
+
+ if state := spanEndStateFromContext(endCtx); state != nil {
+ state.End()
+ return err
+ }
+
+ if endCtx != nil {
+ trace.SpanFromContext(endCtx).End()
+ }
+
+ return err
+}
+
+// WithTelemetryInterceptor is a gRPC interceptor that adds tracing to the context.
+func (tm *TelemetryMiddleware) WithTelemetryInterceptor(tl *tracing.Telemetry) grpc.UnaryServerInterceptor {
+ return func(
+ ctx context.Context,
+ req any,
+ info *grpc.UnaryServerInfo,
+ handler grpc.UnaryHandler,
+ ) (any, error) {
+ ctx = normalizeGRPCContext(ctx)
+
+ effectiveTelemetry := tl
+ if effectiveTelemetry == nil && tm != nil {
+ effectiveTelemetry = tm.Telemetry
+ }
+
+ if effectiveTelemetry == nil {
+ return handler(ctx, req)
+ }
+
+ requestID := resolveGRPCRequestID(ctx, req)
+ ctx = observability.ContextWithHeaderID(ctx, requestID)
+
+ if effectiveTelemetry.TracerProvider == nil {
+ return handler(ctx, req)
+ }
+
+ tracer := effectiveTelemetry.TracerProvider.Tracer(effectiveTelemetry.LibraryName)
+
+ methodName := "unknown"
+ if info != nil {
+ methodName = info.FullMethod
+ }
+
+ ctx = observability.ContextWithSpanAttributes(ctx,
+ attribute.String("app.request.request_id", requestID),
+ attribute.String("grpc.method", methodName),
+ )
+
+ traceCtx := ctx
+ // Compatibility note: trace extraction currently trusts the internal-service
+ // User-Agent heuristic. This is an interoperability hint, not an authenticated
+ // trust boundary, and is preserved to avoid changing existing caller behavior.
+ if isInternalLerianService(getGRPCUserAgent(ctx)) {
+ md, _ := metadata.FromIncomingContext(ctx)
+ traceCtx = tracing.ExtractGRPCContext(ctx, md)
+ }
+
+ ctx, span := tracer.Start(traceCtx, methodName, trace.WithSpanKind(trace.SpanKindServer))
+ endState := newSpanEndState(span)
+
+ defer endState.End()
+
+ ctx = observability.ContextWithTracer(ctx, tracer)
+ ctx = observability.ContextWithMetricFactory(ctx, effectiveTelemetry.MetricsFactory)
+ ctx = contextWithSpanEndState(ctx, endState)
+
+ err := tm.collectMetrics(ctx)
+ if err != nil {
+ tracing.HandleSpanError(span, "Failed to collect metrics", err)
+ }
+
+ resp, err := handler(ctx, req)
+
+ grpcStatusCode := status.Code(err)
+ span.SetAttributes(
+ attribute.String("rpc.method", methodName),
+ attribute.Int("rpc.grpc.status_code", int(grpcStatusCode)),
+ )
+
+ if err != nil {
+ tracing.HandleSpanError(span, "gRPC handler error", err)
+ }
+
+ return resp, err
+ }
+}
+
+// EndTracingSpansInterceptor is a gRPC interceptor that ends the tracing spans.
+func (tm *TelemetryMiddleware) EndTracingSpansInterceptor() grpc.UnaryServerInterceptor {
+ return func(
+ ctx context.Context,
+ req any,
+ info *grpc.UnaryServerInfo,
+ handler grpc.UnaryHandler,
+ ) (any, error) {
+ resp, err := handler(ctx, req)
+ if state := spanEndStateFromContext(ctx); state != nil {
+ state.End()
+ return resp, err
+ }
+
+ trace.SpanFromContext(ctx).End()
+
+ return resp, err
+ }
+}
+
+// setRequestHeaderID ensures the Fiber request carries a unique correlation ID header.
+// The effective ID is always echoed back on the response so that callers can
+// correlate their request regardless of whether the ID was client-supplied or
+// server-generated.
+func setRequestHeaderID(c *fiber.Ctx) {
+ hid := normalizeRequestID(c.Get(headerID))
+
+ if isNilOrEmptyString(&hid) {
+ hid = uuid.New().String()
+ }
+
+ c.Request().Header.Set(headerID, hid)
+ c.Set(headerID, hid)
+ c.Response().Header.Set(headerID, hid)
+
+ ctx := observability.ContextWithHeaderID(c.UserContext(), hid)
+ c.SetUserContext(ctx)
+}
+
+// resolveGRPCRequestID determines the request ID for a gRPC call from the request body,
+// existing context header, gRPC metadata (in that priority order), or generates a new UUID.
+func resolveGRPCRequestID(ctx context.Context, req any) string {
+ if rid, ok := getValidBodyRequestID(req); ok {
+ return rid
+ }
+
+ if existing := getContextHeaderID(ctx); existing != "" {
+ return existing
+ }
+
+ if rid := getMetadataID(ctx); rid != "" {
+ return rid
+ }
+
+ return uuid.New().String()
+}
+
+// getContextHeaderID extracts the HeaderID from the observability context value.
+func getContextHeaderID(ctx context.Context) string {
+ if ctx == nil {
+ return ""
+ }
+
+ cv, ok := ctx.Value(observability.ContextKey).(*observability.ContextValue)
+ if !ok || cv == nil {
+ return ""
+ }
+
+ return normalizeRequestID(cv.HeaderID)
+}
+
+// getValidBodyRequestID extracts and validates the request_id from the gRPC request body.
+// Returns (id, true) when present and valid UUID; otherwise ("", false).
+func getValidBodyRequestID(req any) (string, bool) {
+ if req == nil {
+ return "", false
+ }
+
+ // Check for typed-nil interface.
+ v := reflect.ValueOf(req)
+ if (v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface) && v.IsNil() {
+ return "", false
+ }
+
+ r, ok := req.(interface{ GetRequestId() string })
+ if !ok {
+ return "", false
+ }
+
+ rid := strings.TrimSpace(r.GetRequestId())
+ if rid == "" {
+ return "", false
+ }
+
+ // Validate it is a UUID.
+ if _, err := uuid.Parse(rid); err != nil {
+ return "", false
+ }
+
+ return rid, true
+}
diff --git a/middleware/telemetry_route_test.go b/middleware/telemetry_route_test.go
new file mode 100644
index 0000000..0cc587c
--- /dev/null
+++ b/middleware/telemetry_route_test.go
@@ -0,0 +1,42 @@
+//go:build unit
+
+package middleware
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/LerianStudio/lib-observability/tracing"
+ "github.com/gofiber/fiber/v2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+)
+
+func TestWithTelemetry_UnmatchedRouteDoesNotPanic(t *testing.T) {
+ tp, spanRecorder := setupTestTracer(t)
+ defer func() { _ = tp.Shutdown(context.Background()) }()
+
+ oldTP := otel.GetTracerProvider()
+ otel.SetTracerProvider(tp)
+ defer otel.SetTracerProvider(oldTP)
+
+ tel := &tracing.Telemetry{
+ TelemetryConfig: tracing.TelemetryConfig{LibraryName: "test-library", EnableTelemetry: true},
+ TracerProvider: tp,
+ }
+
+ app := fiber.New()
+ app.Use(NewTelemetryMiddleware(tel).WithTelemetry(tel))
+
+ assert.NotPanics(t, func() {
+ resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/missing", nil))
+ require.NoError(t, err)
+ defer func() { require.NoError(t, resp.Body.Close()) }()
+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
+ })
+
+ assert.NotEmpty(t, spanRecorder.Ended())
+}
diff --git a/middleware/telemetry_test.go b/middleware/telemetry_test.go
new file mode 100644
index 0000000..991f6ac
--- /dev/null
+++ b/middleware/telemetry_test.go
@@ -0,0 +1,901 @@
+//go:build unit
+
+package middleware
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/LerianStudio/lib-observability/metrics"
+ "github.com/LerianStudio/lib-observability/tracing"
+ "github.com/gofiber/fiber/v2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/propagation"
+ sdkmetric "go.opentelemetry.io/otel/sdk/metric"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ "go.opentelemetry.io/otel/sdk/trace/tracetest"
+ oteltrace "go.opentelemetry.io/otel/trace"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/metadata"
+)
+
+// setupTestTracer sets up a test tracer provider and returns it along with a span recorder.
+func setupTestTracer(t *testing.T) (*sdktrace.TracerProvider, *tracetest.SpanRecorder) {
+ t.Helper()
+
+ spanRecorder := tracetest.NewSpanRecorder()
+ tracerProvider := sdktrace.NewTracerProvider(
+ sdktrace.WithSpanProcessor(spanRecorder),
+ )
+
+ oldPropagator := otel.GetTextMapPropagator()
+ otel.SetTextMapPropagator(propagation.TraceContext{})
+ t.Cleanup(func() {
+ otel.SetTextMapPropagator(oldPropagator)
+ })
+
+ return tracerProvider, spanRecorder
+}
+
+// TestWithTelemetry tests the WithTelemetry middleware function.
+func TestWithTelemetry(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ method string
+ setupHandler func(c *fiber.Ctx) error
+ nilTelemetry bool
+ traceparent string
+ expectedStatusCode int
+ expectSpan bool
+ swaggerPath bool
+ }{
+ {
+ name: "Basic middleware functionality",
+ path: "/api/resource",
+ method: "GET",
+ setupHandler: func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) },
+ expectedStatusCode: http.StatusOK,
+ expectSpan: true,
+ },
+ {
+ name: "Handler returns error",
+ path: "/api/resource",
+ method: "POST",
+ setupHandler: func(c *fiber.Ctx) error { return errors.New("handler error") },
+ expectedStatusCode: http.StatusInternalServerError,
+ expectSpan: true,
+ },
+ {
+ name: "Nil telemetry",
+ path: "/api/resource",
+ method: "GET",
+ setupHandler: func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) },
+ nilTelemetry: true,
+ expectedStatusCode: http.StatusOK,
+ expectSpan: false,
+ },
+ {
+ name: "With trace context",
+ path: "/api/resource",
+ method: "GET",
+ setupHandler: func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) },
+ traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ expectedStatusCode: http.StatusOK,
+ expectSpan: true,
+ },
+ {
+ name: "UUID in path",
+ path: "/api/users/123e4567-e89b-12d3-a456-426614174000/profile",
+ method: "GET",
+ setupHandler: func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) },
+ expectedStatusCode: http.StatusOK,
+ expectSpan: true,
+ },
+ {
+ name: "Swagger path bypass",
+ path: "/swagger/api-docs",
+ method: "GET",
+ setupHandler: func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) },
+ expectedStatusCode: http.StatusOK,
+ expectSpan: false,
+ swaggerPath: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := context.Background()
+
+ tp, spanRecorder := setupTestTracer(t)
+ defer func() {
+ _ = tp.Shutdown(ctx)
+ }()
+
+ oldTracerProvider := otel.GetTracerProvider()
+ otel.SetTracerProvider(tp)
+ defer otel.SetTracerProvider(oldTracerProvider)
+
+ var tel *tracing.Telemetry
+ if !tt.nilTelemetry {
+ tel = &tracing.Telemetry{
+ TelemetryConfig: tracing.TelemetryConfig{
+ LibraryName: "test-library",
+ EnableTelemetry: true,
+ },
+ TracerProvider: tp,
+ }
+ }
+
+ mid := NewTelemetryMiddleware(tel)
+
+ app := fiber.New(fiber.Config{
+ ErrorHandler: func(c *fiber.Ctx, err error) error {
+ return c.Status(http.StatusInternalServerError).SendString(err.Error())
+ },
+ })
+
+ if !tt.nilTelemetry {
+ if tt.swaggerPath {
+ app.Use(mid.WithTelemetry(tel, "/swagger"))
+ } else {
+ app.Use(mid.WithTelemetry(tel))
+ }
+ }
+
+ app.All(tt.path, func(c *fiber.Ctx) error {
+ return tt.setupHandler(c)
+ })
+
+ req, err := http.NewRequest(tt.method, tt.path, nil)
+ require.NoError(t, err)
+
+ if tt.traceparent != "" {
+ req.Header.Set("traceparent", tt.traceparent)
+ }
+
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, resp.Body.Close()) }()
+
+ assert.Equal(t, tt.expectedStatusCode, resp.StatusCode)
+
+ spans := spanRecorder.Ended()
+
+ if tt.expectSpan && !tt.nilTelemetry && !tt.swaggerPath {
+ require.GreaterOrEqual(t, len(spans), 1, "Expected at least one span to be created")
+
+ expectedPath := tt.path
+ if strings.Contains(tt.path, "123e4567-e89b-12d3-a456-426614174000") {
+ expectedPath = replaceUUIDWithPlaceholder(tt.path)
+ }
+
+ spanFound := false
+ for _, span := range spans {
+ if span.Name() == tt.method+" "+expectedPath {
+ spanFound = true
+ break
+ }
+ }
+ assert.True(t, spanFound, "Expected span with name %s not found", tt.method+" "+expectedPath)
+ } else if tt.swaggerPath || tt.nilTelemetry {
+ assert.Empty(t, spans, "Expected no spans for swagger path or nil telemetry")
+ }
+ })
+ }
+}
+
+// TestWithTelemetryExcludedRoutes tests the WithTelemetry middleware with excluded routes.
+func TestWithTelemetryExcludedRoutes(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ method string
+ excludedRoutes []string
+ expectSpan bool
+ }{
+ {
+ name: "Route not excluded",
+ path: "/api/users",
+ method: "GET",
+ excludedRoutes: []string{"/swagger", "/health"},
+ expectSpan: true,
+ },
+ {
+ name: "Route excluded by exact match",
+ path: "/swagger/api-docs",
+ method: "GET",
+ excludedRoutes: []string{"/swagger"},
+ expectSpan: false,
+ },
+ {
+ name: "Route excluded by partial match",
+ path: "/health/check",
+ method: "GET",
+ excludedRoutes: []string{"/health"},
+ expectSpan: false,
+ },
+ {
+ name: "Multiple excluded routes",
+ path: "/metrics/prometheus",
+ method: "GET",
+ excludedRoutes: []string{"/swagger", "/health", "/metrics"},
+ expectSpan: false,
+ },
+ {
+ name: "No excluded routes",
+ path: "/api/users",
+ method: "GET",
+ excludedRoutes: []string{},
+ expectSpan: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := context.Background()
+
+ tp, spanRecorder := setupTestTracer(t)
+ defer func() {
+ _ = tp.Shutdown(ctx)
+ }()
+
+ oldTracerProvider := otel.GetTracerProvider()
+ otel.SetTracerProvider(tp)
+ defer otel.SetTracerProvider(oldTracerProvider)
+
+ tel := &tracing.Telemetry{
+ TelemetryConfig: tracing.TelemetryConfig{
+ LibraryName: "test-library",
+ EnableTelemetry: true,
+ },
+ TracerProvider: tp,
+ }
+
+ mid := NewTelemetryMiddleware(tel)
+
+ app := fiber.New()
+ app.Use(mid.WithTelemetry(tel, tt.excludedRoutes...))
+
+ app.All(tt.path, func(c *fiber.Ctx) error {
+ return c.SendStatus(http.StatusOK)
+ })
+
+ req, err := http.NewRequest(tt.method, tt.path, nil)
+ require.NoError(t, err)
+
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, resp.Body.Close()) }()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ spans := spanRecorder.Ended()
+
+ if tt.expectSpan {
+ require.GreaterOrEqual(t, len(spans), 1, "Expected at least one span to be created")
+
+ expectedSpanName := tt.method + " " + replaceUUIDWithPlaceholder(tt.path)
+ spanFound := false
+ for _, span := range spans {
+ if span.Name() == expectedSpanName {
+ spanFound = true
+ break
+ }
+ }
+ assert.True(t, spanFound, "Expected span with name %s not found", expectedSpanName)
+ } else {
+ assert.Empty(t, spans, "Expected no spans for excluded routes")
+ }
+ })
+ }
+}
+
+// TestEndTracingSpans tests the EndTracingSpans middleware function.
+func TestEndTracingSpans(t *testing.T) {
+ tests := []struct {
+ name string
+ setupCtx bool
+ handlerErr error
+ }{
+ {
+ name: "With context",
+ setupCtx: true,
+ handlerErr: nil,
+ },
+ {
+ name: "Without context",
+ setupCtx: false,
+ handlerErr: nil,
+ },
+ {
+ name: "With context and handler error",
+ setupCtx: true,
+ handlerErr: errors.New("handler error"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ spanRecorder := tracetest.NewSpanRecorder()
+ tp := sdktrace.NewTracerProvider(
+ sdktrace.WithSpanProcessor(spanRecorder),
+ )
+ defer func() {
+ _ = tp.Shutdown(context.Background())
+ }()
+
+ tel := &tracing.Telemetry{
+ TelemetryConfig: tracing.TelemetryConfig{
+ LibraryName: "test-library",
+ EnableTelemetry: true,
+ },
+ TracerProvider: tp,
+ }
+
+ mid := NewTelemetryMiddleware(tel)
+
+ app := fiber.New()
+
+ setupMiddleware := func(c *fiber.Ctx) error {
+ ctx := c.UserContext()
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ if tt.setupCtx {
+ tracer := tp.Tracer("test")
+ ctx, _ = tracer.Start(ctx, "test-span")
+ c.SetUserContext(ctx)
+ }
+
+ return c.Next()
+ }
+
+ handler := func(c *fiber.Ctx) error {
+ return tt.handlerErr
+ }
+
+ app.Get("/test", setupMiddleware, mid.EndTracingSpans, handler)
+
+ req := httptest.NewRequest("GET", "/test", nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, resp.Body.Close()) }()
+
+ if tt.handlerErr != nil {
+ assert.Equal(t, fiber.StatusInternalServerError, resp.StatusCode)
+ } else {
+ assert.Equal(t, fiber.StatusOK, resp.StatusCode)
+ }
+
+ if tt.setupCtx {
+ assert.Eventually(t, func() bool {
+ return len(spanRecorder.Ended()) == 1
+ }, time.Second, 10*time.Millisecond, "Expected middleware to end one span")
+
+ spans := spanRecorder.Ended()
+ if assert.Len(t, spans, 1) {
+ assert.Equal(t, "test-span", spans[0].Name())
+ }
+ } else {
+ assert.Never(t, func() bool {
+ return len(spanRecorder.Ended()) > 0
+ }, 100*time.Millisecond, 10*time.Millisecond, "Expected no spans to be ended")
+ }
+ })
+ }
+}
+
+func TestEndTracingSpans_CallsNextWithoutInitialContext(t *testing.T) {
+ t.Parallel()
+
+ app := fiber.New()
+ mid := &TelemetryMiddleware{}
+ handlerCalled := false
+
+ app.Get("/test", mid.EndTracingSpans, func(c *fiber.Ctx) error {
+ handlerCalled = true
+ return c.SendStatus(http.StatusNoContent)
+ })
+
+ resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/test", nil))
+ require.NoError(t, err)
+ defer func() { require.NoError(t, resp.Body.Close()) }()
+
+ assert.True(t, handlerCalled)
+ assert.Equal(t, http.StatusNoContent, resp.StatusCode)
+}
+
+func TestEndTracingSpans_EndsFinalContextSpan(t *testing.T) {
+ t.Parallel()
+
+ tp, spanRecorder := setupTestTracer(t)
+ defer func() { _ = tp.Shutdown(context.Background()) }()
+
+ app := fiber.New()
+ mid := &TelemetryMiddleware{}
+
+ app.Get("/test", mid.EndTracingSpans, func(c *fiber.Ctx) error {
+ ctx, _ := tp.Tracer("test").Start(context.Background(), "handler-span")
+ c.SetUserContext(ctx)
+ return c.SendStatus(http.StatusNoContent)
+ })
+
+ resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/test", nil))
+ require.NoError(t, err)
+ defer func() { require.NoError(t, resp.Body.Close()) }()
+
+ assert.Eventually(t, func() bool {
+ return len(spanRecorder.Ended()) == 1
+ }, time.Second, 10*time.Millisecond)
+ assert.Equal(t, "handler-span", spanRecorder.Ended()[0].Name())
+}
+
+// TestGetMetricsCollectionInterval tests the getMetricsCollectionInterval function.
+func TestGetMetricsCollectionInterval(t *testing.T) {
+ tests := []struct {
+ name string
+ envValue string
+ expected time.Duration
+ }{
+ {
+ name: "default when not set",
+ envValue: "",
+ expected: DefaultMetricsCollectionInterval,
+ },
+ {
+ name: "valid duration in seconds",
+ envValue: "10s",
+ expected: 10 * time.Second,
+ },
+ {
+ name: "valid duration in milliseconds",
+ envValue: "500ms",
+ expected: 500 * time.Millisecond,
+ },
+ {
+ name: "valid duration in minutes",
+ envValue: "1m",
+ expected: 1 * time.Minute,
+ },
+ {
+ name: "invalid format falls back to default",
+ envValue: "invalid",
+ expected: DefaultMetricsCollectionInterval,
+ },
+ {
+ name: "zero value falls back to default",
+ envValue: "0s",
+ expected: DefaultMetricsCollectionInterval,
+ },
+ {
+ name: "negative value falls back to default",
+ envValue: "-5s",
+ expected: DefaultMetricsCollectionInterval,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if tt.envValue != "" {
+ t.Setenv("METRICS_COLLECTION_INTERVAL", tt.envValue)
+ } else {
+ t.Setenv("METRICS_COLLECTION_INTERVAL", "")
+ }
+
+ result := getMetricsCollectionInterval()
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func resetMetricsCollectorState() {
+ metricsCollectorMu.Lock()
+ defer metricsCollectorMu.Unlock()
+
+ if metricsCollectorStarted && metricsCollectorShutdown != nil {
+ close(metricsCollectorShutdown)
+ time.Sleep(50 * time.Millisecond)
+ }
+
+ metricsCollectorShutdown = nil
+ metricsCollectorStarted = false
+ metricsCollectorOnce = &sync.Once{}
+ metricsCollectorInitErr = nil
+}
+
+func TestEnsureMetricsCollector_ReturnsErrorWhenMetricsFactoryNil(t *testing.T) {
+ resetMetricsCollectorState()
+ t.Cleanup(resetMetricsCollectorState)
+
+ mid := &TelemetryMiddleware{Telemetry: &tracing.Telemetry{
+ TelemetryConfig: tracing.TelemetryConfig{LibraryName: "test-library", EnableTelemetry: true},
+ MeterProvider: sdkmetric.NewMeterProvider(),
+ }}
+
+ err := mid.ensureMetricsCollector()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "MetricsFactory is nil")
+ assert.False(t, metricsCollectorStarted)
+}
+
+func TestEnsureMetricsCollector_NoMeterProviderReturnsNil(t *testing.T) {
+ resetMetricsCollectorState()
+ t.Cleanup(resetMetricsCollectorState)
+
+ mid := &TelemetryMiddleware{Telemetry: &tracing.Telemetry{}}
+ require.NoError(t, mid.ensureMetricsCollector())
+ assert.False(t, metricsCollectorStarted)
+}
+
+func TestStopMetricsCollector_AllowsRestart(t *testing.T) {
+ resetMetricsCollectorState()
+ t.Cleanup(resetMetricsCollectorState)
+
+ mid := &TelemetryMiddleware{Telemetry: &tracing.Telemetry{
+ TelemetryConfig: tracing.TelemetryConfig{LibraryName: "test-library", EnableTelemetry: true},
+ MeterProvider: sdkmetric.NewMeterProvider(),
+ MetricsFactory: metrics.NewNopFactory(),
+ }}
+
+ require.NoError(t, mid.ensureMetricsCollector())
+ assert.True(t, metricsCollectorStarted)
+
+ StopMetricsCollector()
+ assert.False(t, metricsCollectorStarted)
+
+ require.NoError(t, mid.ensureMetricsCollector())
+ assert.True(t, metricsCollectorStarted)
+}
+
+// TestExtractHTTPContext tests the ExtractHTTPContext function from tracing package.
+func TestExtractHTTPContext(t *testing.T) {
+ ctx := context.Background()
+
+ tp, _ := setupTestTracer(t)
+ defer func() {
+ _ = tp.Shutdown(ctx)
+ }()
+
+ oldTracerProvider := otel.GetTracerProvider()
+ otel.SetTracerProvider(tp)
+ defer otel.SetTracerProvider(oldTracerProvider)
+
+ traceparent := "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
+
+ app := fiber.New()
+
+ app.Get("/test", func(c *fiber.Ctx) error {
+ ctx := tracing.ExtractHTTPContext(c.UserContext(), c)
+
+ spanCtx := oteltrace.SpanContextFromContext(ctx)
+
+ if c.Get("traceparent") != "" {
+ assert.True(t, spanCtx.IsValid(), "Span context should be valid with traceparent header")
+ assert.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", spanCtx.TraceID().String())
+ assert.Equal(t, "00f067aa0ba902b7", spanCtx.SpanID().String())
+ } else {
+ assert.False(t, spanCtx.IsValid(), "Span context should not be valid without traceparent header")
+ }
+
+ return c.SendStatus(http.StatusOK)
+ })
+
+ req1, err := http.NewRequest("GET", "/test", nil)
+ require.NoError(t, err)
+ req1.Header.Set("traceparent", traceparent)
+
+ resp1, err := app.Test(req1)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, resp1.Body.Close()) }()
+ assert.Equal(t, http.StatusOK, resp1.StatusCode)
+
+ req2, err := http.NewRequest("GET", "/test", nil)
+ require.NoError(t, err)
+
+ resp2, err := app.Test(req2)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, resp2.Body.Close()) }()
+ assert.Equal(t, http.StatusOK, resp2.StatusCode)
+}
+
+// TestWithTelemetryConditionalTracePropagation tests the conditional trace propagation based on UserAgent.
+func TestWithTelemetryConditionalTracePropagation(t *testing.T) {
+ tests := []struct {
+ name string
+ userAgent string
+ traceparent string
+ shouldPropagateTrace bool
+ description string
+ }{
+ {
+ name: "Internal Lerian service - should propagate trace",
+ userAgent: "midaz/1.0.0 LerianStudio",
+ traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ shouldPropagateTrace: true,
+ description: "Internal service with valid UserAgent pattern should propagate trace context",
+ },
+ {
+ name: "External service - should NOT propagate trace",
+ userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
+ traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ shouldPropagateTrace: false,
+ description: "External browser UserAgent should create new root span",
+ },
+ {
+ name: "No UserAgent - should NOT propagate trace",
+ userAgent: "",
+ traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ shouldPropagateTrace: false,
+ description: "Missing UserAgent should create new root span",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := context.Background()
+
+ tp, spanRecorder := setupTestTracer(t)
+ defer func() {
+ _ = tp.Shutdown(ctx)
+ }()
+
+ oldTracerProvider := otel.GetTracerProvider()
+ otel.SetTracerProvider(tp)
+ defer otel.SetTracerProvider(oldTracerProvider)
+
+ tel := &tracing.Telemetry{
+ TelemetryConfig: tracing.TelemetryConfig{
+ LibraryName: "test-library",
+ EnableTelemetry: true,
+ },
+ TracerProvider: tp,
+ }
+
+ mid := NewTelemetryMiddleware(tel)
+
+ app := fiber.New()
+ app.Use(mid.WithTelemetry(tel))
+
+ var capturedSpanContext oteltrace.SpanContext
+ app.Get("/test", func(c *fiber.Ctx) error {
+ capturedSpanContext = oteltrace.SpanContextFromContext(c.UserContext())
+ return c.SendStatus(http.StatusOK)
+ })
+
+ req, err := http.NewRequest("GET", "/test", nil)
+ require.NoError(t, err)
+
+ if tt.userAgent != "" {
+ req.Header.Set("User-Agent", tt.userAgent)
+ }
+
+ req.Header.Set("traceparent", tt.traceparent)
+
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, resp.Body.Close()) }()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ spans := spanRecorder.Ended()
+ require.GreaterOrEqual(t, len(spans), 1, "Expected at least one span to be created")
+
+ if tt.shouldPropagateTrace {
+ assert.True(t, capturedSpanContext.IsValid(), "Span context should be valid for internal services")
+ assert.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", capturedSpanContext.TraceID().String(),
+ "Trace ID should match the traceparent header for internal services")
+ } else {
+ require.True(t, capturedSpanContext.IsValid(), "Expected middleware to attach a valid span context")
+ assert.NotEqual(t, "4bf92f3577b34da6a3ce929d0e0e4736", capturedSpanContext.TraceID().String(),
+ "Trace ID should be different from traceparent header for external services")
+ }
+ })
+ }
+}
+
+// TestGetGRPCUserAgent tests the getGRPCUserAgent helper function.
+func TestGetGRPCUserAgent(t *testing.T) {
+ tests := []struct {
+ name string
+ setupMetadata func() context.Context
+ expectedUA string
+ description string
+ }{
+ {
+ name: "Valid user-agent in metadata",
+ setupMetadata: func() context.Context {
+ md := metadata.Pairs("user-agent", "midaz/1.0.0 LerianStudio")
+ return metadata.NewIncomingContext(context.Background(), md)
+ },
+ expectedUA: "midaz/1.0.0 LerianStudio",
+ description: "Should extract user-agent from gRPC metadata",
+ },
+ {
+ name: "No metadata in context",
+ setupMetadata: func() context.Context {
+ return context.Background()
+ },
+ expectedUA: "",
+ description: "Should return empty string when no metadata present",
+ },
+ {
+ name: "Metadata without user-agent",
+ setupMetadata: func() context.Context {
+ md := metadata.Pairs("authorization", "Bearer token")
+ return metadata.NewIncomingContext(context.Background(), md)
+ },
+ expectedUA: "",
+ description: "Should return empty string when user-agent key not present",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := tt.setupMetadata()
+ result := getGRPCUserAgent(ctx)
+ assert.Equal(t, tt.expectedUA, result, tt.description)
+ })
+ }
+}
+
+// ---------------------------------------------------------------------------
+// sanitizeURL tests
+// ---------------------------------------------------------------------------
+
+func TestSanitizeURL_NoQueryParams(t *testing.T) {
+ t.Parallel()
+
+ result := sanitizeURL("https://example.com/api/v1/users")
+ assert.Equal(t, "https://example.com/api/v1/users", result)
+}
+
+func TestSanitizeURL_NoSensitiveParams(t *testing.T) {
+ t.Parallel()
+
+ result := sanitizeURL("https://example.com/api?page=1&limit=20")
+ assert.Equal(t, "https://example.com/api?page=1&limit=20", result)
+}
+
+func TestSanitizeURL_SensitiveTokenParam(t *testing.T) {
+ t.Parallel()
+
+ result := sanitizeURL("https://example.com/callback?token=secret123&state=abc")
+ assert.NotContains(t, result, "secret123")
+ assert.Contains(t, result, "state=abc")
+}
+
+func TestSanitizeURL_SensitivePasswordParam(t *testing.T) {
+ t.Parallel()
+
+ result := sanitizeURL("https://example.com/auth?password=hunter2&username=admin")
+ assert.NotContains(t, result, "hunter2")
+ assert.Contains(t, result, "username=admin")
+}
+
+func TestSanitizeURL_InvalidURL_ReturnedAsIs(t *testing.T) {
+ t.Parallel()
+
+ invalidURL := "://missing-scheme"
+ result := sanitizeURL(invalidURL)
+ assert.Equal(t, invalidURL, result)
+}
+
+func TestSanitizeURL_InvalidURLWithSensitiveQuery_RedactsFallback(t *testing.T) {
+ t.Parallel()
+
+ result := sanitizeURL("://missing-scheme?token=secret123")
+ assert.NotContains(t, result, "secret123")
+ assert.Contains(t, result, "?redacted")
+}
+
+func TestSanitizeURL_EmptyQueryReturnsOriginal(t *testing.T) {
+ t.Parallel()
+
+ original := "https://example.com/path"
+ result := sanitizeURL(original)
+ assert.Equal(t, original, result)
+}
+
+func TestSanitizeURL_RelativePath(t *testing.T) {
+ t.Parallel()
+
+ result := sanitizeURL("/api/v1/users?token=abc123")
+ assert.NotContains(t, result, "abc123")
+}
+
+// TestWithTelemetryInterceptorConditionalTracePropagation tests conditional trace propagation in gRPC interceptor.
+func TestWithTelemetryInterceptorConditionalTracePropagation(t *testing.T) {
+ tests := []struct {
+ name string
+ userAgent string
+ traceparent string
+ shouldPropagateTrace bool
+ description string
+ }{
+ {
+ name: "Internal Lerian service via gRPC - should propagate trace",
+ userAgent: "midaz/1.0.0 LerianStudio",
+ traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ shouldPropagateTrace: true,
+ description: "Internal gRPC service should propagate trace context",
+ },
+ {
+ name: "External gRPC client - should NOT propagate trace",
+ userAgent: "grpc-go/1.50.0",
+ traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ shouldPropagateTrace: false,
+ description: "External gRPC client should create new root span",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := context.Background()
+
+ tp, spanRecorder := setupTestTracer(t)
+ defer func() {
+ _ = tp.Shutdown(ctx)
+ }()
+
+ oldTracerProvider := otel.GetTracerProvider()
+ otel.SetTracerProvider(tp)
+ defer otel.SetTracerProvider(oldTracerProvider)
+
+ tel := &tracing.Telemetry{
+ TelemetryConfig: tracing.TelemetryConfig{
+ LibraryName: "test-library",
+ EnableTelemetry: true,
+ },
+ TracerProvider: tp,
+ }
+
+ mid := NewTelemetryMiddleware(tel)
+ interceptor := mid.WithTelemetryInterceptor(tel)
+
+ md := metadata.New(map[string]string{})
+ if tt.userAgent != "" {
+ md.Set("user-agent", tt.userAgent)
+ }
+ if tt.traceparent != "" {
+ md.Set("traceparent", tt.traceparent)
+ }
+ ctx = metadata.NewIncomingContext(ctx, md)
+
+ var capturedSpanContext oteltrace.SpanContext
+ handler := func(ctx context.Context, req any) (any, error) {
+ capturedSpanContext = oteltrace.SpanContextFromContext(ctx)
+ return "response", nil
+ }
+
+ info := &grpc.UnaryServerInfo{
+ FullMethod: "/test.Service/Method",
+ }
+
+ _, err := interceptor(ctx, "request", info, handler)
+ require.NoError(t, err)
+
+ spans := spanRecorder.Ended()
+ require.GreaterOrEqual(t, len(spans), 1, "Expected at least one span to be created")
+
+ if tt.shouldPropagateTrace {
+ assert.True(t, capturedSpanContext.IsValid(), "Span context should be valid for internal services")
+ assert.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", capturedSpanContext.TraceID().String(),
+ "Trace ID should match the traceparent for internal gRPC services")
+ } else {
+ require.True(t, capturedSpanContext.IsValid(), "Expected middleware to attach a valid span context")
+ assert.NotEqual(t, "4bf92f3577b34da6a3ce929d0e0e4736", capturedSpanContext.TraceID().String(),
+ "Trace ID should be different from traceparent for external services")
+ }
+ })
+ }
+}
diff --git a/observability.go b/observability.go
new file mode 100644
index 0000000..7cf072c
--- /dev/null
+++ b/observability.go
@@ -0,0 +1,285 @@
+// Package observability provides context carriers for attaching and extracting
+// OpenTelemetry tracers, metric factories, loggers, and request-scoped span
+// attributes. NewTrackingFromContext is the primary convenience entry-point
+// used by service handlers and repository methods to obtain all telemetry
+// components in a single call.
+package observability
+
+import (
+ "context"
+ "strings"
+ "sync"
+
+ "github.com/LerianStudio/lib-observability/log"
+ "github.com/LerianStudio/lib-observability/metrics"
+ "github.com/google/uuid"
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/trace"
+)
+
+// ---- Context key ----
+
+type contextKey string
+
+// ContextKey is the context key used to store ContextValue.
+var ContextKey = contextKey("custom_context")
+
+// ContextValue holds all request-scoped facilities we attach to context.
+type ContextValue struct {
+ HeaderID string
+ Tracer trace.Tracer
+ Logger log.Logger
+ MetricFactory *metrics.MetricsFactory
+
+ // AttrBag holds request-wide attributes to be applied to every span.
+ // Keep low/medium cardinality attributes here (tenant.id, plan, region, request_id, route).
+ AttrBag []attribute.KeyValue
+}
+
+// ---- Logger helpers ----
+
+// NewLoggerFromContext extracts the Logger from context.
+// A nil ctx is normalized to context.Background() so callers never trigger a nil-pointer dereference.
+//
+//nolint:ireturn
+func NewLoggerFromContext(ctx context.Context) log.Logger {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ if cv, ok := ctx.Value(ContextKey).(*ContextValue); ok && cv.Logger != nil {
+ return cv.Logger
+ }
+
+ return &log.NopLogger{}
+}
+
+// cloneContextValues returns a shallow copy of the ContextValue from ctx.
+// This prevents concurrent mutation of a shared struct when multiple goroutines
+// derive child contexts from the same parent.
+// The AttrBag slice is deep-copied to avoid aliasing the underlying array.
+func cloneContextValues(ctx context.Context) *ContextValue {
+ existing, _ := ctx.Value(ContextKey).(*ContextValue)
+
+ clone := &ContextValue{}
+ if existing != nil {
+ *clone = *existing
+
+ // Deep-copy the slice to avoid aliasing the backing array.
+ if len(existing.AttrBag) > 0 {
+ clone.AttrBag = make([]attribute.KeyValue, len(existing.AttrBag))
+ copy(clone.AttrBag, existing.AttrBag)
+ }
+ }
+
+ return clone
+}
+
+// ContextWithLogger returns a context with the given Logger attached.
+func ContextWithLogger(ctx context.Context, logger log.Logger) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ values := cloneContextValues(ctx)
+ values.Logger = logger
+
+ return context.WithValue(ctx, ContextKey, values)
+}
+
+// ---- Tracer helpers ----
+
+// ContextWithTracer returns a context with the given trace.Tracer attached.
+func ContextWithTracer(ctx context.Context, tracer trace.Tracer) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ values := cloneContextValues(ctx)
+ values.Tracer = tracer
+
+ return context.WithValue(ctx, ContextKey, values)
+}
+
+// ---- Metrics helpers ----
+
+// ContextWithMetricFactory returns a context with the given MetricsFactory attached.
+func ContextWithMetricFactory(ctx context.Context, metricFactory *metrics.MetricsFactory) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ values := cloneContextValues(ctx)
+ values.MetricFactory = metricFactory
+
+ return context.WithValue(ctx, ContextKey, values)
+}
+
+// ---- Tracking bundle (convenience) ----
+
+// TrackingComponents represents the complete set of tracking components extracted from context.
+// This struct encapsulates all telemetry-related dependencies in a single, cohesive unit.
+type TrackingComponents struct {
+ Logger log.Logger
+ Tracer trace.Tracer
+ HeaderID string
+ MetricFactory *metrics.MetricsFactory
+}
+
+// NewTrackingFromContext extracts tracking components from context with intelligent fallback.
+// It follows the fail-safe principle: preserve valid components, provide sensible defaults for invalid ones.
+//
+//nolint:ireturn
+func NewTrackingFromContext(ctx context.Context) (log.Logger, trace.Tracer, string, *metrics.MetricsFactory) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ components := extractTrackingComponents(ctx)
+
+ return components.Logger, components.Tracer, components.HeaderID, components.MetricFactory
+}
+
+// extractTrackingComponents performs the core extraction logic with comprehensive fallback strategy.
+func extractTrackingComponents(ctx context.Context) TrackingComponents {
+ cv, ok := ctx.Value(ContextKey).(*ContextValue)
+ if !ok || cv == nil {
+ return newDefaultTrackingComponents()
+ }
+
+ return TrackingComponents{
+ Logger: resolveLogger(cv.Logger),
+ Tracer: resolveTracer(cv.Tracer),
+ HeaderID: resolveHeaderID(cv.HeaderID),
+ MetricFactory: resolveMetricFactory(cv.MetricFactory),
+ }
+}
+
+// resolveLogger applies the Null Object Pattern for logger resolution.
+// Returns a functional logger instance in all cases, eliminating nil checks downstream.
+func resolveLogger(logger log.Logger) log.Logger {
+ if logger != nil {
+ return logger
+ }
+
+ return &log.NopLogger{} // Null Object Pattern - always functional
+}
+
+// resolveTracer ensures a valid tracer is always available using OpenTelemetry best practices.
+// The default tracer maintains observability even when context is incomplete.
+func resolveTracer(tracer trace.Tracer) trace.Tracer {
+ if tracer != nil {
+ return tracer
+ }
+
+ return otel.Tracer("observability.default") // Descriptive tracer name for debugging
+}
+
+// resolveHeaderID implements the correlation ID pattern with UUID fallback.
+// Ensures every request has a unique identifier for distributed tracing.
+//
+// IMPORTANT: When no HeaderID is present in context, a new UUID is generated on
+// every call to NewTrackingFromContext. Ingress middleware (HTTP/gRPC) MUST persist
+// the generated ID back into context via ContextWithSpanAttributes so that downstream
+// extractions within the same request return a stable correlation ID.
+func resolveHeaderID(headerID string) string {
+ if trimmed := strings.TrimSpace(headerID); trimmed != "" {
+ return trimmed
+ }
+
+ return uuid.New().String() // Generate unique correlation ID
+}
+
+var (
+ defaultFactoryOnce sync.Once
+ defaultFactory *metrics.MetricsFactory
+)
+
+func getDefaultMetricsFactory() *metrics.MetricsFactory {
+ defaultFactoryOnce.Do(func() {
+ meter := otel.GetMeterProvider().Meter("observability.default")
+
+ f, err := metrics.NewMetricsFactory(meter, &log.NopLogger{})
+ if err != nil {
+ defaultFactory = metrics.NewNopFactory()
+ return
+ }
+
+ defaultFactory = f
+ })
+
+ return defaultFactory
+}
+
+// resolveMetricFactory ensures a valid metrics factory is always available following the fail-safe pattern.
+// Provides a cached default factory when none exists, initialized once via sync.Once.
+// Never returns nil: if factory creation fails, it falls back to a no-op factory.
+func resolveMetricFactory(factory *metrics.MetricsFactory) *metrics.MetricsFactory {
+ if factory != nil {
+ return factory
+ }
+
+ return getDefaultMetricsFactory()
+}
+
+// newDefaultTrackingComponents creates a complete set of default components.
+// Used when context extraction fails entirely - ensures system remains operational.
+func newDefaultTrackingComponents() TrackingComponents {
+ return TrackingComponents{
+ Logger: &log.NopLogger{},
+ Tracer: otel.Tracer("observability.default"),
+ HeaderID: uuid.New().String(),
+ MetricFactory: resolveMetricFactory(nil),
+ }
+}
+
+// ---- Attribute Bag (request-wide span attributes) ----
+
+// ContextWithSpanAttributes appends one or more attributes to the request's AttrBag.
+// Call this once at the ingress (HTTP/gRPC middleware) and avoid per-layer duplication.
+// Example keys: tenant.id, enduser.id, request.route, region, plan.
+func ContextWithSpanAttributes(ctx context.Context, kv ...attribute.KeyValue) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ if len(kv) == 0 {
+ return ctx
+ }
+
+ values := cloneContextValues(ctx)
+ // Append to the cloned (independent) slice.
+ values.AttrBag = append(values.AttrBag, kv...)
+
+ return context.WithValue(ctx, ContextKey, values)
+}
+
+// AttributesFromContext returns a shallow copy of the AttrBag slice, safe to reuse by processors.
+func AttributesFromContext(ctx context.Context) []attribute.KeyValue {
+ if ctx == nil {
+ return nil
+ }
+
+ if values, ok := ctx.Value(ContextKey).(*ContextValue); ok && values != nil && len(values.AttrBag) > 0 {
+ out := make([]attribute.KeyValue, len(values.AttrBag))
+ copy(out, values.AttrBag)
+
+ return out
+ }
+
+ return nil
+}
+
+// ReplaceAttributes resets the current AttrBag with a new set of request-wide span attributes.
+func ReplaceAttributes(ctx context.Context, kv ...attribute.KeyValue) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ values := cloneContextValues(ctx)
+
+ values.AttrBag = append([]attribute.KeyValue(nil), kv...)
+
+ return context.WithValue(ctx, ContextKey, values)
+}
diff --git a/observability_test.go b/observability_test.go
new file mode 100644
index 0000000..726c39b
--- /dev/null
+++ b/observability_test.go
@@ -0,0 +1,30 @@
+//go:build unit
+
+package observability
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "go.opentelemetry.io/otel/attribute"
+)
+
+func TestReplaceAttributes(t *testing.T) {
+ ctx := ContextWithSpanAttributes(context.Background(),
+ attribute.String("service.name", "old"),
+ attribute.String("tenant.id", "tenant-1"),
+ )
+
+ ctx = ReplaceAttributes(ctx, attribute.String("service.name", "new"))
+
+ attrs := AttributesFromContext(ctx)
+ assert.Equal(t, []attribute.KeyValue{attribute.String("service.name", "new")}, attrs)
+}
+
+func TestReplaceAttributesNilContext(t *testing.T) {
+ ctx := ReplaceAttributes(nil, attribute.String("service.name", "new"))
+
+ attrs := AttributesFromContext(ctx)
+ assert.Equal(t, []attribute.KeyValue{attribute.String("service.name", "new")}, attrs)
+}
diff --git a/redaction/doc.go b/redaction/doc.go
new file mode 100644
index 0000000..fd2385c
--- /dev/null
+++ b/redaction/doc.go
@@ -0,0 +1,6 @@
+// Package redaction provides sensitive field detection for log and span attribute
+// redaction. IsSensitiveField checks a field name against a default list of
+// credentials, PII, and financial identifiers, with support for camelCase
+// normalization and word-boundary matching. Callers may extend the default list
+// with additional field names via the variadic extra parameter.
+package redaction
diff --git a/redaction/sensitive_fields.go b/redaction/sensitive_fields.go
new file mode 100644
index 0000000..0d0ec32
--- /dev/null
+++ b/redaction/sensitive_fields.go
@@ -0,0 +1,287 @@
+package redaction
+
+import (
+ "maps"
+ "regexp"
+ "slices"
+ "strings"
+ "sync"
+ "unicode"
+)
+
+var defaultSensitiveFields = []string{
+ "password",
+ "newpassword",
+ "oldpassword",
+ "passwordsalt",
+ "token",
+ "secret",
+ "key",
+ "authorization",
+ "auth",
+ "credential",
+ "credentials",
+ "apikey",
+ "api_key",
+ "access_token",
+ "accesstoken",
+ "refresh_token",
+ "refreshtoken",
+ "bearer",
+ "jwt",
+ "session_id",
+ "sessionid",
+ "cookie",
+ "private_key",
+ "privatekey",
+ "clientid",
+ "client_id",
+ "clientsecret",
+ "client_secret",
+ "passwd",
+ "passphrase",
+ "card_number",
+ "cardnumber",
+ "cvv",
+ "cvc",
+ "ssn",
+ "social_security",
+ "pin",
+ "otp",
+ "account_number",
+ "accountnumber",
+ "routing_number",
+ "routingnumber",
+ "iban",
+ "swift",
+ "swift_code",
+ "bic",
+ "pan",
+ "expiry",
+ "expiry_date",
+ "expiration_date",
+ "card_expiry",
+ "date_of_birth",
+ "dob",
+ "tax_id",
+ "taxid",
+ "tin",
+ "national_id",
+ "sort_code",
+ "bsb",
+ "security_answer",
+ "security_question",
+ "mother_maiden_name",
+ "mfa_code",
+ "totp",
+ "biometric",
+ "fingerprint",
+ "certificate",
+ "connection_string",
+ "database_url",
+ // PII fields
+ "email",
+ "phone",
+ "phone_number",
+ "address",
+ "street",
+ "city",
+ "zip",
+ "postal_code",
+}
+
+var (
+ sensitiveFieldsMapOnce sync.Once
+ sensitiveFieldsMap map[string]bool
+)
+
+// DefaultSensitiveFields returns a copy of the default sensitive field names.
+// The returned slice is a clone — callers cannot mutate shared state.
+func DefaultSensitiveFields() []string {
+ clone := make([]string, len(defaultSensitiveFields))
+ copy(clone, defaultSensitiveFields)
+
+ return clone
+}
+
+// ensureSensitiveFieldsMap returns the internal map directly (no clone).
+// For internal use only where we just need read access.
+func ensureSensitiveFieldsMap() map[string]bool {
+ sensitiveFieldsMapOnce.Do(func() {
+ sensitiveFieldsMap = make(map[string]bool, len(defaultSensitiveFields))
+ for _, field := range defaultSensitiveFields {
+ sensitiveFieldsMap[field] = true
+ }
+ })
+
+ return sensitiveFieldsMap
+}
+
+// DefaultSensitiveFieldsMap provides a map version of DefaultSensitiveFields
+// for lookup operations. All field names are lowercase for
+// case-insensitive matching. The underlying cache is initialized only once;
+// each call returns a shallow clone so callers cannot mutate shared state.
+func DefaultSensitiveFieldsMap() map[string]bool {
+ m := ensureSensitiveFieldsMap()
+ clone := make(map[string]bool, len(m))
+ maps.Copy(clone, m)
+
+ return clone
+}
+
+// shortSensitiveTokens contains tokens that are too short or generic for
+// substring matching and require exact token matching instead.
+var shortSensitiveTokens = map[string]bool{
+ "key": true,
+ "auth": true,
+ "pin": true,
+ "otp": true,
+ "cvv": true,
+ "cvc": true,
+ "ssn": true,
+ "pan": true,
+ "bic": true,
+ "bsb": true,
+ "dob": true,
+ "tin": true,
+ "jwt": true,
+ "zip": true,
+ "city": true,
+}
+
+// tokenSplitRegex splits field names by non-alphanumeric characters.
+var tokenSplitRegex = regexp.MustCompile(`[^a-zA-Z0-9]+`)
+
+// normalizeFieldName converts camelCase and PascalCase field names into
+// underscore-delimited lowercase tokens. For example, "sessionToken" becomes
+// "session_token" and "APIKey" becomes "api_key". This ensures that sensitive
+// field detection works for camelCase naming conventions.
+func normalizeFieldName(fieldName string) string {
+ var b strings.Builder
+
+ runes := []rune(fieldName)
+
+ for i, r := range runes {
+ if i > 0 {
+ prev := runes[i-1]
+
+ var next rune
+ if i+1 < len(runes) {
+ next = runes[i+1]
+ }
+
+ if unicode.IsUpper(r) &&
+ (unicode.IsLower(prev) || unicode.IsDigit(prev) ||
+ (unicode.IsUpper(prev) && next != 0 && unicode.IsLower(next))) {
+ b.WriteByte('_')
+ }
+ }
+
+ b.WriteRune(r)
+ }
+
+ return strings.ToLower(b.String())
+}
+
+// IsSensitiveField checks if a field name is considered sensitive based on
+// the default sensitive fields list plus any extra fields provided. The check
+// is case-insensitive and handles camelCase field names by normalizing them to
+// underscore-delimited tokens. Short tokens (like "key", "auth") use exact
+// token matching to avoid false positives, while longer patterns use
+// word-boundary matching.
+//
+// Extra fields are additional field names to treat as sensitive beyond the
+// built-in default list. Pass them as individual string arguments.
+func IsSensitiveField(fieldName string, extra ...string) bool {
+ m := ensureSensitiveFieldsMap()
+ lowerField := strings.ToLower(fieldName)
+
+ // Check exact match with lowercase against defaults
+ if m[lowerField] {
+ return true
+ }
+
+ // Check exact match against extra fields
+ for _, e := range extra {
+ if strings.EqualFold(fieldName, e) {
+ return true
+ }
+ }
+
+ // Also check with camelCase normalization (e.g., "sessionToken" -> "session_token")
+ normalized := normalizeFieldName(fieldName)
+ if normalized != lowerField && m[normalized] {
+ return true
+ }
+
+ // Merge tokens from both representations for token matching
+ tokens := tokenSplitRegex.Split(normalized, -1)
+
+ for _, sensitive := range defaultSensitiveFields {
+ if shortSensitiveTokens[sensitive] {
+ if slices.Contains(tokens, sensitive) {
+ return true
+ }
+ } else {
+ if matchesWordBoundary(normalized, sensitive) {
+ return true
+ }
+
+ if normalized != lowerField && matchesWordBoundary(lowerField, sensitive) {
+ return true
+ }
+ }
+ }
+
+ // Check extra fields with word-boundary matching on the normalized name
+ for _, e := range extra {
+ eLower := strings.ToLower(e)
+ if matchesWordBoundary(normalized, eLower) {
+ return true
+ }
+ }
+
+ return false
+}
+
+// matchesWordBoundary checks if the pattern appears in the field with word boundaries.
+// A word boundary is either the start/end of string or a non-alphanumeric character.
+func matchesWordBoundary(field, pattern string) bool {
+ if len(pattern) == 0 {
+ return false
+ }
+
+ idx := strings.Index(field, pattern)
+ if idx == -1 {
+ return false
+ }
+
+ for idx != -1 {
+ start := idx
+ end := idx + len(pattern)
+
+ startOk := start == 0 || !isAlphanumeric(field[start-1])
+ endOk := end == len(field) || !isAlphanumeric(field[end])
+
+ if startOk && endOk {
+ return true
+ }
+
+ if end >= len(field) {
+ break
+ }
+
+ nextIdx := strings.Index(field[end:], pattern)
+ if nextIdx == -1 {
+ break
+ }
+
+ idx = end + nextIdx
+ }
+
+ return false
+}
+
+func isAlphanumeric(b byte) bool {
+ return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
+}
diff --git a/redaction/sensitive_fields_test.go b/redaction/sensitive_fields_test.go
new file mode 100644
index 0000000..4e9fd01
--- /dev/null
+++ b/redaction/sensitive_fields_test.go
@@ -0,0 +1,76 @@
+//go:build unit
+
+package redaction
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDefaultSensitiveFieldsReturnsCopy(t *testing.T) {
+ t.Parallel()
+
+ fields := DefaultSensitiveFields()
+ require.NotEmpty(t, fields)
+ fields[0] = "mutated"
+
+ assert.NotEqual(t, "mutated", DefaultSensitiveFields()[0])
+}
+
+func TestDefaultSensitiveFieldsMapReturnsCopy(t *testing.T) {
+ t.Parallel()
+
+ fields := DefaultSensitiveFieldsMap()
+ require.True(t, fields["password"])
+ fields["password"] = false
+ fields["custom"] = true
+
+ next := DefaultSensitiveFieldsMap()
+ assert.True(t, next["password"])
+ assert.False(t, next["custom"])
+}
+
+func TestIsSensitiveField(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ field string
+ extra []string
+ want bool
+ }{
+ {name: "exact default", field: "password", want: true},
+ {name: "case insensitive", field: "PassWord", want: true},
+ {name: "camel case normalized", field: "sessionToken", want: true},
+ {name: "acronym camel case normalized", field: "APIKey", want: true},
+ {name: "short token word boundary", field: "api_key_hash", want: true},
+ {name: "short token avoids substring", field: "monkey", want: false},
+ {name: "long token word boundary", field: "customer_email_hash", want: true},
+ {name: "extra exact", field: "tenantSecret", extra: []string{"tenantSecret"}, want: true},
+ {name: "extra word boundary", field: "billing_custom_field", extra: []string{"custom"}, want: true},
+ {name: "safe field", field: "publicIdentifier", want: false},
+ }
+
+ for _, tt := range cases {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want, IsSensitiveField(tt.field, tt.extra...))
+ })
+ }
+}
+
+func TestMatchesWordBoundary(t *testing.T) {
+ t.Parallel()
+
+ assert.False(t, matchesWordBoundary("anything", ""))
+ assert.False(t, matchesWordBoundary("customeremail", "email"))
+ assert.True(t, matchesWordBoundary("customer_email", "email"))
+ assert.True(t, matchesWordBoundary("email_address", "email"))
+ assert.True(t, matchesWordBoundary("customer-email-value", "email"))
+ assert.False(t, isAlphanumeric('-'))
+ assert.True(t, isAlphanumeric('a'))
+ assert.True(t, isAlphanumeric('Z'))
+ assert.True(t, isAlphanumeric('9'))
+}
diff --git a/runtime/doc.go b/runtime/doc.go
new file mode 100644
index 0000000..a640644
--- /dev/null
+++ b/runtime/doc.go
@@ -0,0 +1,5 @@
+// Package runtime provides policy-driven panic recovery with full observability
+// integration: span event recording, panic counter metrics, structured logging,
+// and optional external error reporter forwarding. Includes safe goroutine launchers
+// and production mode for stack trace redaction.
+package runtime
diff --git a/runtime/error_reporter.go b/runtime/error_reporter.go
new file mode 100644
index 0000000..554eebb
--- /dev/null
+++ b/runtime/error_reporter.go
@@ -0,0 +1,204 @@
+package runtime
+
+import (
+ "context"
+ "fmt"
+ "reflect"
+ "sync"
+)
+
+// ErrorReporter defines an interface for external error reporting services.
+// This abstraction allows integration with error tracking services (e.g., logging
+// to Grafana Loki, sending to an alerting system) without creating a hard
+// dependency on any specific SDK.
+//
+// Implementations should:
+// - Handle nil contexts gracefully
+// - Be safe for concurrent use
+// - Not panic themselves
+type ErrorReporter interface {
+ // CaptureException reports a panic/exception to the error tracking service.
+ // The tags map can include metadata like "component", "goroutine_name", etc.
+ CaptureException(ctx context.Context, err error, tags map[string]string)
+}
+
+// errorReporterInstance is the singleton error reporter.
+// It remains nil unless explicitly configured.
+var (
+ errorReporterInstance ErrorReporter
+ errorReporterMu sync.RWMutex
+)
+
+// SetErrorReporter configures the global error reporter for panic reporting.
+// Pass nil to disable error reporting.
+//
+// This should be called once during application startup if an external
+// error tracking service is desired.
+//
+// Example with structured logging:
+//
+// type logReporter struct {
+// logger *slog.Logger
+// }
+//
+// func (r *logReporter) CaptureException(ctx context.Context, err error, tags map[string]string) {
+// attrs := make([]any, 0, len(tags)*2)
+// for k, v := range tags {
+// attrs = append(attrs, k, v)
+// }
+// r.logger.ErrorContext(ctx, "panic recovered", append(attrs, "error", err)...)
+// }
+//
+// runtime.SetErrorReporter(&logReporter{logger: slog.Default()})
+func SetErrorReporter(reporter ErrorReporter) {
+ errorReporterMu.Lock()
+ defer errorReporterMu.Unlock()
+
+ errorReporterInstance = reporter
+}
+
+// GetErrorReporter returns the currently configured error reporter.
+// Returns nil if no reporter has been configured.
+func GetErrorReporter() ErrorReporter {
+ errorReporterMu.RLock()
+ defer errorReporterMu.RUnlock()
+
+ return errorReporterInstance
+}
+
+var (
+ // productionMode controls whether sensitive data is redacted in error reports.
+ // When true, stack traces and detailed panic values are suppressed.
+ productionMode bool
+ productionModeMu sync.RWMutex
+)
+
+const redactedPanicMsg = "panic recovered (details redacted)"
+
+// SetProductionMode enables or disables production mode for error reporting.
+// In production mode, stack traces and potentially sensitive panic details are redacted.
+func SetProductionMode(enabled bool) {
+ productionModeMu.Lock()
+ defer productionModeMu.Unlock()
+
+ productionMode = enabled
+}
+
+// IsProductionMode returns whether production mode is enabled.
+func IsProductionMode() bool {
+ productionModeMu.RLock()
+ defer productionModeMu.RUnlock()
+
+ return productionMode
+}
+
+// reportPanicToErrorService reports a panic to the configured error reporter if one exists.
+// This is called internally by recovery functions.
+// In production mode, stack traces and potentially sensitive panic values are redacted.
+func reportPanicToErrorService(
+ ctx context.Context,
+ panicValue any,
+ stack []byte,
+ component, goroutineName string,
+) {
+ reporter := GetErrorReporter()
+ if reporter == nil {
+ return
+ }
+
+ isProduction := IsProductionMode()
+
+ // Convert panic value to error, redacting details in production
+ err := toPanicError(panicValue, isProduction)
+
+ tags := map[string]string{
+ "component": component,
+ "goroutine_name": goroutineName,
+ "panic_type": "recovered",
+ }
+
+ // Include stack trace only in non-production mode
+ if len(stack) > 0 && !isProduction {
+ stackStr := string(stack)
+
+ const maxStackLen = 4096
+ if len(stackStr) > maxStackLen {
+ stackStr = stackStr[:maxStackLen] + "\n...[truncated]"
+ }
+
+ tags["stack_trace"] = stackStr
+ }
+
+ func() {
+ defer func() {
+ _ = recover()
+ }()
+
+ reporter.CaptureException(ctx, err, tags)
+ }()
+}
+
+// panicError wraps a panic value as an error for reporting.
+type panicError struct {
+ message string
+}
+
+// Error returns the panic error message.
+func (e *panicError) Error() string {
+ return e.message
+}
+
+func toPanicError(panicValue any, isProduction bool) error {
+ if isProduction {
+ return &panicError{message: redactedPanicMsg}
+ }
+
+ // Guard against typed-nil error values: an interface holding (type=*MyError, value=nil)
+ // would pass the type assertion but panic on .Error(). Use reflect to detect this.
+ if err, ok := panicValue.(error); ok && !isTypedNil(panicValue) {
+ return err
+ }
+
+ if message, ok := panicValue.(string); ok {
+ return &panicError{message: message}
+ }
+
+ return &panicError{message: "panic: " + formatPanicValue(panicValue)}
+}
+
+// isTypedNil returns true if v is an interface holding a nil pointer/nil value.
+func isTypedNil(v any) bool {
+ if v == nil {
+ return false // untyped nil is not a typed nil
+ }
+
+ rv := reflect.ValueOf(v)
+
+ switch rv.Kind() {
+ case reflect.Pointer, reflect.Interface, reflect.Slice, reflect.Map, reflect.Chan, reflect.Func:
+ return rv.IsNil()
+ default:
+ return false
+ }
+}
+
+// formatPanicValue formats a panic value as a string.
+func formatPanicValue(value any) string {
+ if value == nil {
+ return ""
+ }
+
+ // Guard against typed-nil values that would panic on method calls.
+ if isTypedNil(value) {
+ return fmt.Sprintf("<%T>(nil)", value)
+ }
+
+ switch val := value.(type) {
+ case string:
+ return val
+ case error:
+ return val.Error()
+ default:
+ return fmt.Sprintf("%v", value)
+ }
+}
diff --git a/runtime/goroutine.go b/runtime/goroutine.go
new file mode 100644
index 0000000..a7f9bf9
--- /dev/null
+++ b/runtime/goroutine.go
@@ -0,0 +1,93 @@
+package runtime
+
+import (
+ "context"
+
+ "github.com/LerianStudio/lib-observability/log"
+)
+
+// SafeGo launches a goroutine with panic recovery. If the goroutine panics,
+// the panic is handled according to the specified policy.
+//
+// Note: This function does not record metrics or span events because it lacks
+// context. For observability integration, use SafeGoWithContext instead.
+//
+// Parameters:
+// - logger: Logger for recording panic information
+// - name: Descriptive name for the goroutine (used in logs)
+// - policy: How to handle panics (KeepRunning or CrashProcess)
+// - fn: The function to execute in the goroutine
+//
+// Example:
+//
+// runtime.SafeGo(logger, "email-sender", runtime.KeepRunning, func() {
+// sendEmail(to, subject, body)
+// })
+func SafeGo(logger Logger, name string, policy PanicPolicy, fn func()) {
+ if fn == nil {
+ if logger != nil {
+ logger.Log(context.Background(), log.LevelWarn,
+ "SafeGo called with nil callback, ignoring",
+ log.String("goroutine", name),
+ )
+ }
+
+ return
+ }
+
+ go func() {
+ defer RecoverWithPolicy(logger, name, policy)
+
+ fn()
+ }()
+}
+
+// SafeGoWithContext launches a goroutine with panic recovery and context
+// propagation.
+//
+// Note: For better observability labeling, prefer SafeGoWithContextAndComponent.
+func SafeGoWithContext(
+ ctx context.Context,
+ logger Logger,
+ name string,
+ policy PanicPolicy,
+ fn func(context.Context),
+) {
+ SafeGoWithContextAndComponent(ctx, logger, "", name, policy, fn)
+}
+
+// SafeGoWithContextAndComponent is like SafeGoWithContext but also records the
+// provided component name in observability signals.
+//
+// Parameters:
+// - ctx: Context for cancellation, values, and observability
+// - logger: Logger for recording panic information
+// - component: The service component (e.g., "transaction", "onboarding")
+// - name: Descriptive name for the goroutine (used in logs and metrics)
+// - policy: How to handle panics (KeepRunning or CrashProcess)
+// - fn: The function to execute, receiving the context
+func SafeGoWithContextAndComponent(
+ ctx context.Context,
+ logger Logger,
+ component, name string,
+ policy PanicPolicy,
+ fn func(context.Context),
+) {
+ if fn == nil {
+ if logger != nil {
+ logger.Log(context.Background(), log.LevelWarn,
+ "SafeGoWithContextAndComponent called with nil callback, ignoring",
+ log.String("component", component),
+ log.String("goroutine", name),
+ )
+ }
+
+ return
+ }
+
+ go func() {
+ defer RecoverWithPolicyAndContext(ctx, logger, component, name, policy)
+
+ fn(ctx)
+ }()
+}
diff --git a/runtime/goroutine_test.go b/runtime/goroutine_test.go
new file mode 100644
index 0000000..8070c37
--- /dev/null
+++ b/runtime/goroutine_test.go
@@ -0,0 +1,449 @@
+//go:build unit
+
+package runtime
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestPanicPolicyString tests the String method of PanicPolicy.
+func TestPanicPolicyString(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ policy PanicPolicy
+ expected string
+ }{
+ {
+ name: "KeepRunning",
+ policy: KeepRunning,
+ expected: "KeepRunning",
+ },
+ {
+ name: "CrashProcess",
+ policy: CrashProcess,
+ expected: "CrashProcess",
+ },
+ {
+ name: "Unknown",
+ policy: PanicPolicy(99),
+ expected: "Unknown",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ result := tt.policy.String()
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+// TestRecoverAndLog_NoPanic tests that RecoverAndLog does nothing when no panic occurs.
+func TestRecoverAndLog_NoPanic(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+
+ func() {
+ defer RecoverAndLog(logger, "test-no-panic")
+ // No panic here
+ }()
+
+ assert.False(t, logger.wasPanicLogged(), "Should not log when no panic occurs")
+}
+
+// TestRecoverAndLog_WithPanic tests that RecoverAndLog catches and logs panics.
+func TestRecoverAndLog_WithPanic(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+
+ func() {
+ defer RecoverAndLog(logger, "test-with-panic")
+
+ panic("test panic value")
+ }()
+
+ assert.True(t, logger.wasPanicLogged(), "Should log when panic occurs")
+ assert.NotEmpty(t, logger.errorCalls, "Should have logged error")
+}
+
+// TestRecoverAndCrash_NoPanic tests that RecoverAndCrash does nothing when no panic occurs.
+func TestRecoverAndCrash_NoPanic(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+
+ func() {
+ defer RecoverAndCrash(logger, "test-no-panic")
+ // No panic here
+ }()
+
+ assert.False(t, logger.wasPanicLogged(), "Should not log when no panic occurs")
+}
+
+// TestRecoverAndCrash_WithPanic tests that RecoverAndCrash catches, logs, and re-panics.
+func TestRecoverAndCrash_WithPanic(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+
+ defer func() {
+ r := recover()
+ require.NotNil(t, r, "Should re-panic after logging")
+ assert.Equal(t, "test panic value", r)
+ }()
+
+ func() {
+ defer RecoverAndCrash(logger, "test-with-panic")
+
+ panic("test panic value")
+ }()
+
+ t.Fatal("Should not reach here - panic should propagate")
+}
+
+// TestRecoverWithPolicy_KeepRunning tests policy-based recovery with KeepRunning.
+func TestRecoverWithPolicy_KeepRunning(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+
+ func() {
+ defer RecoverWithPolicy(logger, "test-keep-running", KeepRunning)
+
+ panic("test panic")
+ }()
+
+ assert.True(t, logger.wasPanicLogged(), "Should log the panic")
+}
+
+// TestRecoverWithPolicy_CrashProcess tests policy-based recovery with CrashProcess.
+func TestRecoverWithPolicy_CrashProcess(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+
+ defer func() {
+ r := recover()
+ require.NotNil(t, r, "Should re-panic with CrashProcess policy")
+ }()
+
+ func() {
+ defer RecoverWithPolicy(logger, "test-crash", CrashProcess)
+
+ panic("test panic")
+ }()
+
+ t.Fatal("Should not reach here")
+}
+
+// TestSafeGo_NoPanic tests SafeGo with a function that doesn't panic.
+func TestSafeGo_NoPanic(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+ done := make(chan struct{})
+
+ SafeGo(logger, "test-no-panic", KeepRunning, func() {
+ close(done)
+ })
+
+ select {
+ case <-done:
+ // Success - goroutine completed
+ case <-time.After(time.Second):
+ t.Fatal("Goroutine did not complete in time")
+ }
+
+ assert.False(t, logger.wasPanicLogged(), "Should not log when no panic occurs")
+}
+
+// TestSafeGo_WithPanic_KeepRunning tests SafeGo catching panics with KeepRunning policy.
+func TestSafeGo_WithPanic_KeepRunning(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+ done := make(chan struct{})
+
+ SafeGo(logger, "test-panic-keep-running", KeepRunning, func() {
+ defer close(done)
+
+ panic("goroutine panic")
+ })
+
+ select {
+ case <-done:
+ // Success - goroutine completed (panic was caught)
+ case <-time.After(time.Second):
+ t.Fatal("Goroutine did not complete in time")
+ }
+
+ require.True(t, logger.waitForPanicLog(time.Second), "Should log the panic")
+}
+
+// TestSafeGoWithContext_NoPanic tests SafeGoWithContext with no panic.
+func TestSafeGoWithContext_NoPanic(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+ ctx := context.Background()
+ done := make(chan struct{})
+
+ SafeGoWithContext(ctx, logger, "test-ctx-no-panic", KeepRunning, func(ctx context.Context) {
+ close(done)
+ })
+
+ select {
+ case <-done:
+ // Success
+ case <-time.After(time.Second):
+ t.Fatal("Goroutine did not complete in time")
+ }
+}
+
+// TestSafeGoWithContext_WithCancellation tests context cancellation propagation.
+func TestSafeGoWithContext_WithCancellation(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+
+ SafeGoWithContext(ctx, logger, "test-ctx-cancel", KeepRunning, func(ctx context.Context) {
+ <-ctx.Done()
+ close(done)
+ })
+
+ cancel()
+
+ select {
+ case <-done:
+ // Success - context cancellation was received
+ case <-time.After(time.Second):
+ t.Fatal("Goroutine did not receive cancellation in time")
+ }
+}
+
+// TestSafeGoWithContext_WithPanic tests SafeGoWithContext catching panics.
+func TestSafeGoWithContext_WithPanic(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+ ctx := context.Background()
+ done := make(chan struct{})
+
+ SafeGoWithContext(ctx, logger, "test-ctx-panic", KeepRunning, func(ctx context.Context) {
+ defer close(done)
+
+ panic("context goroutine panic")
+ })
+
+ select {
+ case <-done:
+ // Success - panic was caught
+ case <-time.After(time.Second):
+ t.Fatal("Goroutine did not complete in time")
+ }
+
+ require.True(t, logger.waitForPanicLog(time.Second), "Should log the panic")
+}
+
+// TestSafeGoWithContext_WithComponent tests SafeGoWithContextAndComponent.
+func TestSafeGoWithContext_WithComponent(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+ ctx := context.Background()
+ done := make(chan struct{})
+
+ SafeGoWithContextAndComponent(
+ ctx,
+ logger,
+ "transaction",
+ "test-component",
+ KeepRunning,
+ func(ctx context.Context) {
+ defer close(done)
+
+ panic("component panic")
+ },
+ )
+
+ select {
+ case <-done:
+ // Success - panic was caught
+ case <-time.After(time.Second):
+ t.Fatal("Goroutine did not complete in time")
+ }
+
+ require.True(t, logger.waitForPanicLog(time.Second), "Should log the panic")
+}
+
+// TestRecoverWithPolicyAndContext_KeepRunning tests context-aware recovery with KeepRunning.
+func TestRecoverWithPolicyAndContext_KeepRunning(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+ ctx := context.Background()
+
+ func() {
+ defer RecoverWithPolicyAndContext(
+ ctx,
+ logger,
+ "test-component",
+ "test-handler",
+ KeepRunning,
+ )
+
+ panic("context panic")
+ }()
+
+ assert.True(t, logger.wasPanicLogged(), "Should log the panic")
+}
+
+// TestRecoverWithPolicyAndContext_CrashProcess tests context-aware recovery with CrashProcess.
+func TestRecoverWithPolicyAndContext_CrashProcess(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+ ctx := context.Background()
+
+ defer func() {
+ r := recover()
+ require.NotNil(t, r, "Should re-panic with CrashProcess policy")
+ }()
+
+ func() {
+ defer RecoverWithPolicyAndContext(ctx, logger, "test-component", "test-crash", CrashProcess)
+
+ panic("crash panic")
+ }()
+
+ t.Fatal("Should not reach here")
+}
+
+// TestRecoverAndLogWithContext tests RecoverAndLogWithContext.
+func TestRecoverAndLogWithContext(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+ ctx := context.Background()
+
+ func() {
+ defer RecoverAndLogWithContext(ctx, logger, "test-component", "test-handler")
+
+ panic("log context panic")
+ }()
+
+ assert.True(t, logger.wasPanicLogged(), "Should log the panic")
+}
+
+// TestRecoverAndCrashWithContext tests RecoverAndCrashWithContext.
+func TestRecoverAndCrashWithContext(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+ ctx := context.Background()
+
+ defer func() {
+ r := recover()
+ require.NotNil(t, r, "Should re-panic after logging")
+ assert.Equal(t, "crash context panic", r)
+ }()
+
+ func() {
+ defer RecoverAndCrashWithContext(ctx, logger, "test-component", "test-crash")
+
+ panic("crash context panic")
+ }()
+
+ t.Fatal("Should not reach here - panic should propagate")
+}
+
+// TestPanicMetrics_NilFactory tests that nil factory doesn't cause panic.
+func TestPanicMetrics_NilFactory(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+
+ var pm *PanicMetrics
+ pm.RecordPanicRecovered(ctx, "test", "test")
+
+ recordPanicMetric(ctx, "test", "test")
+}
+
+// TestErrorReporter_NilReporter tests that nil reporter doesn't cause panic.
+func TestErrorReporter_NilReporter(t *testing.T) {
+ //nolint:paralleltest // modifies global errorReporterInstance
+
+ ctx := context.Background()
+
+ SetErrorReporter(nil)
+ t.Cleanup(func() { SetErrorReporter(nil) })
+
+ reportPanicToErrorService(ctx, "test panic", nil, "test", "test")
+
+ assert.Nil(t, GetErrorReporter())
+}
+
+// TestErrorReporter_CustomReporter tests custom error reporter integration.
+//
+//nolint:paralleltest // Cannot use t.Parallel() - modifies global errorReporterInstance
+func TestErrorReporter_CustomReporter(t *testing.T) {
+ ctx := context.Background()
+
+ var capturedErr error
+
+ var capturedTags map[string]string
+
+ mockReporter := &mockErrorReporter{
+ captureFunc: func(ctx context.Context, err error, tags map[string]string) {
+ capturedErr = err
+ capturedTags = tags
+ },
+ }
+
+ SetErrorReporter(nil)
+ SetErrorReporter(mockReporter)
+
+ t.Cleanup(func() { SetErrorReporter(nil) })
+
+ reportPanicToErrorService(
+ ctx,
+ "test panic",
+ []byte("test stack trace"),
+ "transaction",
+ "worker",
+ )
+
+ require.NotNil(t, capturedErr)
+ assert.Contains(t, capturedErr.Error(), "test panic")
+ assert.Equal(t, "transaction", capturedTags["component"])
+ assert.Equal(t, "worker", capturedTags["goroutine_name"])
+ assert.Equal(t, "test stack trace", capturedTags["stack_trace"])
+}
+
+// mockErrorReporter is a test implementation of ErrorReporter.
+type mockErrorReporter struct {
+ captureFunc func(ctx context.Context, err error, tags map[string]string)
+}
+
+func (m *mockErrorReporter) CaptureException(
+ ctx context.Context,
+ err error,
+ tags map[string]string,
+) {
+ if m.captureFunc != nil {
+ m.captureFunc(ctx, err, tags)
+ }
+}
diff --git a/runtime/helpers_test.go b/runtime/helpers_test.go
new file mode 100644
index 0000000..3644b62
--- /dev/null
+++ b/runtime/helpers_test.go
@@ -0,0 +1,59 @@
+//go:build unit
+
+package runtime
+
+import (
+ "context"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/LerianStudio/lib-observability/log"
+)
+
+// testLogger is a test logger that captures log calls.
+// It is shared across all runtime test files.
+type testLogger struct {
+ mu sync.Mutex
+ errorCalls []string
+ lastMessage string
+ panicLogged atomic.Bool
+ logged chan struct{} // Signals when a panic was logged
+}
+
+func newTestLogger() *testLogger {
+ return &testLogger{
+ logged: make(chan struct{}, 1), // Buffered to avoid blocking
+ }
+}
+
+func (logger *testLogger) Log(_ context.Context, lvl log.Level, msg string, _ ...log.Field) {
+ logger.mu.Lock()
+ defer logger.mu.Unlock()
+
+ logger.errorCalls = append(logger.errorCalls, msg)
+ logger.lastMessage = msg
+ if lvl == log.LevelError && strings.Contains(strings.ToLower(msg), "panic") {
+ logger.panicLogged.Store(true)
+ }
+
+ // Signal that logging occurred (non-blocking)
+ select {
+ case logger.logged <- struct{}{}:
+ default:
+ }
+}
+
+func (logger *testLogger) wasPanicLogged() bool {
+ return logger.panicLogged.Load()
+}
+
+func (logger *testLogger) waitForPanicLog(timeout time.Duration) bool {
+ select {
+ case <-logger.logged:
+ return true
+ case <-time.After(timeout):
+ return false
+ }
+}
diff --git a/runtime/log_mode_link_test.go b/runtime/log_mode_link_test.go
new file mode 100644
index 0000000..39a5646
--- /dev/null
+++ b/runtime/log_mode_link_test.go
@@ -0,0 +1,48 @@
+//go:build unit
+
+package runtime
+
+import (
+ "bytes"
+ "context"
+ slog "log"
+ "sync"
+ "testing"
+
+ "github.com/LerianStudio/lib-observability/log"
+ "github.com/stretchr/testify/assert"
+)
+
+var runtimeLoggerOutputMu sync.Mutex
+
+func withRuntimeLoggerOutput(t *testing.T, output *bytes.Buffer) {
+ t.Helper()
+
+ runtimeLoggerOutputMu.Lock()
+ defer t.Cleanup(func() {
+ runtimeLoggerOutputMu.Unlock()
+ })
+
+ originalOutput := slog.Writer()
+ slog.SetOutput(output)
+ t.Cleanup(func() { slog.SetOutput(originalOutput) })
+}
+
+func TestLogProductionModeResolverRegistration(t *testing.T) {
+ var buf bytes.Buffer
+ withRuntimeLoggerOutput(t, &buf)
+
+ logger := &log.GoLogger{Level: log.LevelInfo}
+ initialMode := IsProductionMode()
+ t.Cleanup(func() { SetProductionMode(initialMode) })
+
+ SetProductionMode(false)
+ log.SafeError(logger, context.Background(), "runtime integration", assert.AnError, IsProductionMode())
+ assert.Contains(t, buf.String(), "general error")
+
+ buf.Reset()
+ SetProductionMode(true)
+ log.SafeError(logger, context.Background(), "runtime integration", assert.AnError, IsProductionMode())
+ assert.Contains(t, buf.String(), "error_type=*errors.errorString")
+ assert.NotContains(t, buf.String(), "general error")
+}
diff --git a/runtime/metrics.go b/runtime/metrics.go
new file mode 100644
index 0000000..d6dc777
--- /dev/null
+++ b/runtime/metrics.go
@@ -0,0 +1,136 @@
+package runtime
+
+import (
+ "context"
+ "sync"
+
+ constant "github.com/LerianStudio/lib-observability/constants"
+ "github.com/LerianStudio/lib-observability/log"
+ "github.com/LerianStudio/lib-observability/metrics"
+)
+
+// PanicMetrics provides panic-related metrics using OpenTelemetry.
+// It wraps lib-observability's MetricsFactory for consistent metric handling.
+type PanicMetrics struct {
+ factory *metrics.MetricsFactory
+ logger Logger
+}
+
+// panicRecoveredMetric defines the metric for counting recovered panics.
+var panicRecoveredMetric = metrics.Metric{
+ Name: constant.MetricPanicRecoveredTotal,
+ Unit: "1",
+ Description: "Total number of recovered panics",
+}
+
+// panicMetricsInstance is the singleton instance for panic metrics.
+// It is initialized lazily via InitPanicMetrics.
+var (
+ panicMetricsInstance *PanicMetrics
+ panicMetricsMu sync.RWMutex
+)
+
+// InitPanicMetrics initializes panic metrics with the provided MetricsFactory.
+//
+// Backward compatibility:
+// - InitPanicMetrics(factory)
+// - InitPanicMetrics(factory, logger)
+//
+// The logger is optional and used only for metric recording diagnostics.
+// This should be called once during application startup after telemetry is initialized.
+// It is safe to call multiple times; subsequent calls are no-ops.
+//
+// Example:
+//
+// tl, err := tracing.NewTelemetry(cfg)
+// if err != nil {
+// // handle error
+// }
+// tl.ApplyGlobals()
+// runtime.InitPanicMetrics(tl.MetricsFactory)
+func InitPanicMetrics(factory *metrics.MetricsFactory, logger ...Logger) {
+ panicMetricsMu.Lock()
+ defer panicMetricsMu.Unlock()
+
+ if factory == nil {
+ return
+ }
+
+ if panicMetricsInstance != nil {
+ return // Already initialized
+ }
+
+ var l Logger
+ if len(logger) > 0 {
+ l = logger[0]
+ }
+
+ panicMetricsInstance = &PanicMetrics{
+ factory: factory,
+ logger: l,
+ }
+}
+
+// GetPanicMetrics returns the singleton PanicMetrics instance.
+// Returns nil if InitPanicMetrics has not been called.
+func GetPanicMetrics() *PanicMetrics {
+ panicMetricsMu.RLock()
+ defer panicMetricsMu.RUnlock()
+
+ return panicMetricsInstance
+}
+
+// ResetPanicMetrics clears the panic metrics singleton.
+// This is primarily intended for testing to ensure test isolation.
+// In production, this should generally not be called.
+func ResetPanicMetrics() {
+ panicMetricsMu.Lock()
+ defer panicMetricsMu.Unlock()
+
+ panicMetricsInstance = nil
+}
+
+// RecordPanicRecovered increments the panic_recovered_total counter with the given labels.
+// If metrics are not initialized, this is a no-op.
+//
+// Parameters:
+// - ctx: Context for metric recording (may contain trace correlation)
+// - component: The component where the panic occurred (e.g., "transaction", "onboarding", "crm")
+// - goroutineName: The name of the goroutine or handler (e.g., "http_handler", "rabbitmq_worker")
+func (pm *PanicMetrics) RecordPanicRecovered(ctx context.Context, component, goroutineName string) {
+ if pm == nil || pm.factory == nil {
+ return
+ }
+
+ counter, err := pm.factory.Counter(panicRecoveredMetric)
+ if err != nil {
+ if pm.logger != nil {
+ pm.logger.Log(ctx, log.LevelWarn, "failed to create panic metric counter", log.Err(err))
+ }
+
+ return
+ }
+
+ err = counter.
+ WithLabels(map[string]string{
+ "component": constant.SanitizeMetricLabel(component),
+ "goroutine_name": constant.SanitizeMetricLabel(goroutineName),
+ }).
+ AddOne(ctx)
+ if err != nil {
+ if pm.logger != nil {
+ pm.logger.Log(ctx, log.LevelWarn, "failed to record panic metric", log.Err(err))
+ }
+
+ return
+ }
+}
+
+// recordPanicMetric is a package-level helper that records a panic metric if metrics are initialized.
+// This is called internally by recovery functions.
+func recordPanicMetric(ctx context.Context, component, goroutineName string) {
+ pm := GetPanicMetrics()
+ if pm != nil {
+ pm.RecordPanicRecovered(ctx, component, goroutineName)
+ }
+}
diff --git a/runtime/metrics_test.go b/runtime/metrics_test.go
new file mode 100644
index 0000000..5e954a8
--- /dev/null
+++ b/runtime/metrics_test.go
@@ -0,0 +1,58 @@
+//go:build unit
+
+package runtime
+
+import (
+ "strings"
+ "testing"
+
+ constant "github.com/LerianStudio/lib-observability/constants"
+ "github.com/stretchr/testify/assert"
+)
+
+// TestSanitizeMetricLabel tests the shared constant.SanitizeMetricLabel function.
+func TestSanitizeMetricLabel(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "empty string",
+ input: "",
+ expected: "",
+ },
+ {
+ name: "short string",
+ input: "component",
+ expected: "component",
+ },
+ {
+ name: "exactly max length",
+ input: strings.Repeat("a", constant.MaxMetricLabelLength),
+ expected: strings.Repeat("a", constant.MaxMetricLabelLength),
+ },
+ {
+ name: "exceeds max length",
+ input: strings.Repeat("b", constant.MaxMetricLabelLength+10),
+ expected: strings.Repeat("b", constant.MaxMetricLabelLength),
+ },
+ {
+ name: "much longer than max",
+ input: strings.Repeat("c", 200),
+ expected: strings.Repeat("c", constant.MaxMetricLabelLength),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ result := constant.SanitizeMetricLabel(tt.input)
+ assert.Equal(t, tt.expected, result)
+ assert.LessOrEqual(t, len(result), constant.MaxMetricLabelLength)
+ })
+ }
+}
diff --git a/runtime/policy.go b/runtime/policy.go
new file mode 100644
index 0000000..6d8dd1f
--- /dev/null
+++ b/runtime/policy.go
@@ -0,0 +1,28 @@
+package runtime
+
+// PanicPolicy determines how a recovered panic should be handled.
+type PanicPolicy int
+
+const (
+ // KeepRunning logs the panic and stack trace, then continues execution.
+ // Use for HTTP/gRPC handlers and worker goroutines where crashing would
+ // affect other requests or tasks.
+ KeepRunning PanicPolicy = iota
+
+ // CrashProcess logs the panic and stack trace, then re-panics to crash
+ // the process. Use for critical invariant violations where continuing
+ // would cause data corruption or undefined behavior.
+ CrashProcess
+)
+
+// String returns the string representation of the PanicPolicy.
+func (p PanicPolicy) String() string {
+ switch p {
+ case KeepRunning:
+ return "KeepRunning"
+ case CrashProcess:
+ return "CrashProcess"
+ default:
+ return "Unknown"
+ }
+}
diff --git a/runtime/policy_test.go b/runtime/policy_test.go
new file mode 100644
index 0000000..6483314
--- /dev/null
+++ b/runtime/policy_test.go
@@ -0,0 +1,110 @@
+//go:build unit
+
+package runtime
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// TestPanicPolicy_String tests the String method for all PanicPolicy values.
+func TestPanicPolicy_String(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ policy PanicPolicy
+ expected string
+ }{
+ {
+ name: "KeepRunning returns correct string",
+ policy: KeepRunning,
+ expected: "KeepRunning",
+ },
+ {
+ name: "CrashProcess returns correct string",
+ policy: CrashProcess,
+ expected: "CrashProcess",
+ },
+ {
+ name: "Unknown positive value returns Unknown",
+ policy: PanicPolicy(99),
+ expected: "Unknown",
+ },
+ {
+ name: "Negative value returns Unknown",
+ policy: PanicPolicy(-1),
+ expected: "Unknown",
+ },
+ {
+ name: "Large value returns Unknown",
+ policy: PanicPolicy(1000),
+ expected: "Unknown",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ result := tt.policy.String()
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+// TestPanicPolicy_IotaOrdering verifies the iota constant ordering.
+func TestPanicPolicy_IotaOrdering(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ policy PanicPolicy
+ expectedValue int
+ }{
+ {
+ name: "KeepRunning is 0 (first iota)",
+ policy: KeepRunning,
+ expectedValue: 0,
+ },
+ {
+ name: "CrashProcess is 1 (second iota)",
+ policy: CrashProcess,
+ expectedValue: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, tt.expectedValue, int(tt.policy))
+ })
+ }
+}
+
+// TestPanicPolicy_TypeSafety verifies type conversion behavior.
+func TestPanicPolicy_TypeSafety(t *testing.T) {
+ t.Parallel()
+
+ t.Run("explicit int conversion works", func(t *testing.T) {
+ t.Parallel()
+
+ p := KeepRunning
+ assert.Equal(t, 0, int(p))
+
+ p = CrashProcess
+ assert.Equal(t, 1, int(p))
+ })
+
+ t.Run("policy from int conversion", func(t *testing.T) {
+ t.Parallel()
+
+ p := PanicPolicy(0)
+ assert.Equal(t, KeepRunning, p)
+
+ p = PanicPolicy(1)
+ assert.Equal(t, CrashProcess, p)
+ })
+}
diff --git a/runtime/recover.go b/runtime/recover.go
new file mode 100644
index 0000000..6920762
--- /dev/null
+++ b/runtime/recover.go
@@ -0,0 +1,233 @@
+package runtime
+
+import (
+ "context"
+ "runtime/debug"
+
+ "github.com/LerianStudio/lib-observability/log"
+)
+
+// Logger defines the minimal logging interface required by runtime.
+// This interface is satisfied by github.com/LerianStudio/lib-observability/log.Logger.
+type Logger interface {
+ Log(ctx context.Context, level log.Level, msg string, fields ...log.Field)
+}
+
+// RecoverAndLog recovers from a panic, logs it with the stack trace, and
+// continues execution. Use this in defer statements for handlers and workers
+// where you want to prevent crashes.
+//
+// Note: This function does not record metrics or span events because it lacks
+// context. For observability integration, use RecoverAndLogWithContext instead.
+//
+// Example:
+//
+// func worker() {
+// defer runtime.RecoverAndLog(logger, "worker")
+// // ...
+// }
+func RecoverAndLog(logger Logger, name string) {
+ if r := recover(); r != nil {
+ logPanic(logger, name, r)
+ }
+}
+
+// RecoverAndLogWithContext is like RecoverAndLog but with full observability integration.
+// It records metrics, span events, and reports to error tracking services.
+//
+// Parameters:
+// - ctx: Context for observability (metrics, tracing, error reporting)
+// - logger: Logger for structured logging
+// - component: The service component (e.g., "transaction", "onboarding")
+// - name: Descriptive name for the goroutine or handler
+//
+// Example:
+//
+// func handler(ctx context.Context) {
+// defer runtime.RecoverAndLogWithContext(ctx, logger, "transaction", "create_handler")
+// // ...
+// }
+func RecoverAndLogWithContext(ctx context.Context, logger Logger, component, name string) {
+ if r := recover(); r != nil {
+ stack := debug.Stack()
+ logPanicWithStack(ctx, logger, name, r, stack)
+ recordPanicObservability(ctx, r, stack, component, name)
+ }
+}
+
+// RecoverAndCrash recovers from a panic, logs it with the stack trace, and
+// re-panics to crash the process. Use this in defer statements for critical
+// operations where continuing after a panic would be dangerous.
+//
+// Example:
+//
+// func criticalOperation() {
+// defer runtime.RecoverAndCrash(logger, "critical-op")
+// // ...
+// }
+func RecoverAndCrash(logger Logger, name string) {
+ if r := recover(); r != nil {
+ logPanic(logger, name, r)
+ panic(r)
+ }
+}
+
+// RecoverAndCrashWithContext is like RecoverAndCrash but with full observability integration.
+// It records metrics and span events before re-panicking.
+//
+// Parameters:
+// - ctx: Context for observability (metrics, tracing, error reporting)
+// - logger: Logger for structured logging
+// - component: The service component (e.g., "transaction", "onboarding")
+// - name: Descriptive name for the goroutine or handler
+func RecoverAndCrashWithContext(ctx context.Context, logger Logger, component, name string) {
+ if r := recover(); r != nil {
+ stack := debug.Stack()
+ logPanicWithStack(ctx, logger, name, r, stack)
+ recordPanicObservability(ctx, r, stack, component, name)
+ panic(r)
+ }
+}
+
+// RecoverWithPolicy recovers from a panic and handles it according to the
+// specified policy. Use this when the recovery behavior needs to be determined
+// at runtime.
+//
+// Note: This function does not record metrics or span events because it lacks
+// context. For observability integration, use RecoverWithPolicyAndContext instead.
+//
+// Example:
+//
+// func flexibleHandler(policy runtime.PanicPolicy) {
+// defer runtime.RecoverWithPolicy(logger, "handler", policy)
+// // ...
+// }
+func RecoverWithPolicy(logger Logger, name string, policy PanicPolicy) {
+ if r := recover(); r != nil {
+ logPanic(logger, name, r)
+
+ if policy == CrashProcess {
+ panic(r)
+ }
+ }
+}
+
+// RecoverWithPolicyAndContext is like RecoverWithPolicy but with full observability integration.
+// It records metrics, span events, and reports to error tracking services.
+//
+// Parameters:
+// - ctx: Context for observability (metrics, tracing, error reporting)
+// - logger: Logger for structured logging
+// - component: The service component (e.g., "transaction", "onboarding")
+// - name: Descriptive name for the goroutine or handler
+// - policy: How to handle the panic after logging/recording
+//
+// Example:
+//
+// func worker(ctx context.Context, policy runtime.PanicPolicy) {
+// defer runtime.RecoverWithPolicyAndContext(ctx, logger, "transaction", "balance_worker", policy)
+// // ...
+// }
+func RecoverWithPolicyAndContext(
+ ctx context.Context,
+ logger Logger,
+ component, name string,
+ policy PanicPolicy,
+) {
+ if recovered := recover(); recovered != nil {
+ stack := debug.Stack()
+ logPanicWithStack(ctx, logger, name, recovered, stack)
+ recordPanicObservability(ctx, recovered, stack, component, name)
+
+ if policy == CrashProcess {
+ panic(recovered)
+ }
+ }
+}
+
+// logPanic logs the panic value and stack trace using the provided logger.
+// This is the legacy function that captures stack internally.
+func logPanic(logger Logger, name string, panicValue any) {
+ stack := debug.Stack()
+ logPanicWithStack(context.Background(), logger, name, panicValue, stack)
+}
+
+// logPanicWithStack logs the panic with a pre-captured stack trace.
+// In production mode, panic values are redacted to prevent leaking sensitive data.
+func logPanicWithStack(ctx context.Context, logger Logger, name string, panicValue any, stack []byte) {
+ if logger == nil {
+ // Last resort fallback - should never happen in production
+ return
+ }
+
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ if IsProductionMode() {
+ logger.Log(ctx, log.LevelError,
+ "panic recovered",
+ log.String("source", name),
+ log.String("value", redactedPanicMsg),
+ )
+
+ return
+ }
+
+ logger.Log(ctx, log.LevelError,
+ "panic recovered",
+ log.String("source", name),
+ log.Any("value", panicValue),
+ log.String("stack_trace", string(stack)),
+ )
+}
+
+// recordPanicObservability records panic information to all configured observability systems.
+// This includes metrics, distributed tracing, and error reporting services.
+func recordPanicObservability(
+ ctx context.Context,
+ panicValue any,
+ stack []byte,
+ component, name string,
+) {
+ // Record metric
+ recordPanicMetric(ctx, component, name)
+
+ // Record span event
+ RecordPanicToSpanWithComponent(ctx, panicValue, stack, component, name)
+
+ // Report to error tracking service (e.g., Sentry) if configured
+ reportPanicToErrorService(ctx, panicValue, stack, component, name)
+}
+
+// HandlePanicValue processes a panic value that was already recovered by an external
+// mechanism (e.g., Fiber's recover middleware). This function logs and records
+// observability data without calling recover() itself.
+//
+// Use this when integrating with frameworks that provide their own panic recovery
+// but still need our observability pipeline.
+//
+// Parameters:
+// - ctx: Context for observability (metrics, tracing, error reporting)
+// - logger: Logger for structured logging
+// - panicValue: The panic value recovered by the external mechanism
+// - component: The service component (e.g., "matcher", "ingestion")
+// - name: Descriptive name for the handler (e.g., "http_handler")
+//
+// Example (Fiber middleware):
+//
+// recover.New(recover.Config{
+// StackTraceHandler: func(c *fiber.Ctx, panicValue any) {
+// ctx := extractContext(c)
+// runtime.HandlePanicValue(ctx, logger, panicValue, "matcher", "http_handler")
+// },
+// })
+func HandlePanicValue(ctx context.Context, logger Logger, panicValue any, component, name string) {
+ if panicValue == nil {
+ return
+ }
+
+ stack := debug.Stack()
+ logPanicWithStack(ctx, logger, name, panicValue, stack)
+ recordPanicObservability(ctx, panicValue, stack, component, name)
+}
diff --git a/runtime/recover_test.go b/runtime/recover_test.go
new file mode 100644
index 0000000..bae8064
--- /dev/null
+++ b/runtime/recover_test.go
@@ -0,0 +1,187 @@
+//go:build unit
+
+package runtime
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var (
+ errTestPanicRecover = errors.New("test error")
+ errOriginalPanicRecover = errors.New("original error")
+)
+
+// TestLogPanicWithStack_NilLogger tests that nil logger doesn't cause panic.
+func TestLogPanicWithStack_NilLogger(t *testing.T) {
+ t.Parallel()
+
+ require.NotPanics(t, func() {
+ logPanicWithStack(context.Background(), nil, "test", "panic value", []byte("stack trace"))
+ })
+}
+
+// TestLogPanicWithStack_ValidLogger tests logging with a valid logger.
+func TestLogPanicWithStack_ValidLogger(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+ stack := []byte("goroutine 1 [running]:\nmain.main()\n\t/path/to/file.go:10")
+
+ logPanicWithStack(context.Background(), logger, "test-handler", "test panic", stack)
+
+ assert.True(t, logger.wasPanicLogged())
+ assert.NotEmpty(t, logger.errorCalls)
+}
+
+// TestLogPanicWithStack_DifferentPanicTypes tests various panic value types.
+func TestLogPanicWithStack_DifferentPanicTypes(t *testing.T) {
+ t.Parallel()
+
+ type customStruct struct {
+ Field string
+ Code int
+ }
+
+ tests := []struct {
+ name string
+ panicValue any
+ }{
+ {
+ name: "string panic value",
+ panicValue: "something went wrong",
+ },
+ {
+ name: "error panic value",
+ panicValue: errTestPanicRecover,
+ },
+ {
+ name: "int panic value",
+ panicValue: 42,
+ },
+ {
+ name: "struct panic value",
+ panicValue: customStruct{Field: "test", Code: 500},
+ },
+ {
+ name: "nil panic value",
+ panicValue: nil,
+ },
+ {
+ name: "bool panic value",
+ panicValue: true,
+ },
+ {
+ name: "float panic value",
+ panicValue: 3.14159,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+ require.NotPanics(t, func() {
+ logPanicWithStack(context.Background(), logger, "test-handler", tt.panicValue, []byte("stack"))
+ })
+ })
+ }
+}
+
+// TestRecoverAndLog_NilLogger tests recovery with nil logger.
+func TestRecoverAndLog_NilLogger(t *testing.T) {
+ t.Parallel()
+
+ require.NotPanics(t, func() {
+ func() {
+ defer RecoverAndLog(nil, "test")
+ panic("test panic")
+ }()
+ })
+}
+
+// TestRecoverAndCrash_NilLogger tests RecoverAndCrash with nil logger.
+func TestRecoverAndCrash_NilLogger(t *testing.T) {
+ t.Parallel()
+
+ defer func() {
+ r := recover()
+ require.NotNil(t, r, "Should re-panic even with nil logger")
+ }()
+
+ func() {
+ defer RecoverAndCrash(nil, "test")
+ panic("test crash nil logger")
+ }()
+
+ t.Fatal("Should not reach here")
+}
+
+// TestRecoverWithPolicy_NilLogger tests recovery with nil logger.
+func TestRecoverWithPolicy_NilLogger(t *testing.T) {
+ t.Parallel()
+
+ require.NotPanics(t, func() {
+ func() {
+ defer RecoverWithPolicy(nil, "test", KeepRunning)
+ panic("nil logger test")
+ }()
+ })
+}
+
+// TestRecoverWithPolicyAndContext_NilCtx tests recovery with nil context.
+func TestRecoverWithPolicyAndContext_NilCtx(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+
+ var nilCtx context.Context
+
+ require.NotPanics(t, func() {
+ func() {
+ defer RecoverWithPolicyAndContext(nilCtx, logger, "test", "test", KeepRunning)
+ panic("test")
+ }()
+ })
+}
+
+// TestSafeGo_NilFunction tests SafeGo with nil function.
+func TestSafeGo_NilFunction(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+
+ require.NotPanics(t, func() {
+ SafeGo(logger, "test-nil-fn", KeepRunning, nil)
+ })
+}
+
+// TestSafeGoWithContext_NilFunction tests SafeGoWithContext with nil function.
+func TestSafeGoWithContext_NilFunction(t *testing.T) {
+ t.Parallel()
+
+ logger := newTestLogger()
+
+ require.NotPanics(t, func() {
+ SafeGoWithContext(context.Background(), logger, "test-nil-fn", KeepRunning, nil)
+ })
+}
+
+// TestToPanicError_ErrorValue tests toPanicError with error panic value.
+func TestToPanicError_ErrorValue(t *testing.T) {
+ t.Parallel()
+
+ // Test via production mode - production mode returns error type, not message
+ errNonProd := toPanicError(errOriginalPanicRecover, false)
+ require.NotNil(t, errNonProd)
+ assert.Contains(t, errNonProd.Error(), errOriginalPanicRecover.Error())
+
+ errProd := toPanicError(errOriginalPanicRecover, true)
+ require.NotNil(t, errProd)
+ assert.NotEqual(t, errNonProd.Error(), errProd.Error(), "production mode should sanitize panic details")
+}
diff --git a/runtime/tracing.go b/runtime/tracing.go
new file mode 100644
index 0000000..6b13690
--- /dev/null
+++ b/runtime/tracing.go
@@ -0,0 +1,141 @@
+package runtime
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "regexp"
+
+ constant "github.com/LerianStudio/lib-observability/constants"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/codes"
+ "go.opentelemetry.io/otel/trace"
+)
+
+// maxPanicValueLen is the maximum length for a panic value string exported to spans.
+const maxPanicValueLen = 1024
+
+// maxStackTraceLen is the maximum length for a stack trace string exported to spans.
+const maxStackTraceLen = 4096
+
+// sensitivePattern matches common sensitive data patterns for redaction in span attributes.
+// Covers passwords, tokens, secrets, API keys, credentials, and connection strings.
+var sensitivePattern = regexp.MustCompile(
+ `(?i)(password|passwd|pwd|token|secret|api[_-]?key|credential|bearer|authorization)[=:]\s*\S+`,
+)
+
+// sensitiveRedaction is the replacement string for redacted sensitive data.
+const sensitiveRedaction = "[REDACTED]"
+
+// sanitizePanicValue truncates and redacts sensitive patterns from a panic value string.
+func sanitizePanicValue(raw string) string {
+ sanitized := sensitivePattern.ReplaceAllString(raw, sensitiveRedaction)
+
+ if len(sanitized) > maxPanicValueLen {
+ return sanitized[:maxPanicValueLen] + "...[truncated]"
+ }
+
+ return sanitized
+}
+
+// sanitizeStackTrace truncates a stack trace for safe span export.
+func sanitizeStackTrace(stack []byte) string {
+ s := string(stack)
+
+ if len(s) > maxStackTraceLen {
+ return s[:maxStackTraceLen] + "\n...[truncated]"
+ }
+
+ return s
+}
+
+// ErrPanic is the sentinel error for recovered panics recorded to spans.
+var ErrPanic = errors.New("panic")
+
+// PanicSpanEventName is the event name used when recording panic events on spans.
+const PanicSpanEventName = constant.EventPanicRecovered
+
+// RecordPanicToSpan records a recovered panic as an error event on the current span.
+// This enriches distributed traces with panic information for debugging.
+//
+// The function:
+// - Adds a "panic.recovered" event with panic value, stack trace, and goroutine name
+// - Records the panic as an error using span.RecordError
+// - Sets the span status to Error with a descriptive message
+//
+// Parameters:
+// - ctx: Context containing the active span
+// - panicValue: The value passed to panic()
+// - stack: The stack trace captured via debug.Stack()
+// - goroutineName: The name of the goroutine where the panic occurred
+//
+// If there is no active span in the context, this function is a no-op.
+func RecordPanicToSpan(ctx context.Context, panicValue any, stack []byte, goroutineName string) {
+ recordPanicToSpanInternal(ctx, panicValue, stack, "", goroutineName)
+}
+
+// RecordPanicToSpanWithComponent is like RecordPanicToSpan but also includes the component name.
+// This is useful for HTTP/gRPC handlers where both component and handler name are relevant.
+//
+// Parameters:
+// - ctx: Context containing the active span
+// - panicValue: The value passed to panic()
+// - stack: The stack trace captured via debug.Stack()
+// - component: The service component (e.g., "transaction", "onboarding")
+// - goroutineName: The name of the handler or goroutine
+func RecordPanicToSpanWithComponent(
+ ctx context.Context,
+ panicValue any,
+ stack []byte,
+ component, goroutineName string,
+) {
+ recordPanicToSpanInternal(ctx, panicValue, stack, component, goroutineName)
+}
+
+// recordPanicToSpanInternal is the shared implementation for recording panic events.
+// Panic values and stack traces are sanitized to prevent leaking sensitive data
+// into distributed tracing backends.
+func recordPanicToSpanInternal(
+ ctx context.Context,
+ panicValue any,
+ stack []byte,
+ component, goroutineName string,
+) {
+ if ctx == nil {
+ return
+ }
+
+ span := trace.SpanFromContext(ctx)
+ if !span.IsRecording() {
+ return
+ }
+
+ panicStr := sanitizePanicValue(fmt.Sprintf("%v", panicValue))
+ stackStr := sanitizeStackTrace(stack)
+
+ // Build attributes list
+ attrs := []attribute.KeyValue{
+ attribute.String("panic.value", panicStr),
+ attribute.String("panic.stack", stackStr),
+ attribute.String("panic.goroutine_name", goroutineName),
+ }
+
+ // Add component if provided
+ if component != "" {
+ attrs = append(attrs, attribute.String("panic.component", component))
+ }
+
+ // Add detailed event with all panic information
+ span.AddEvent(PanicSpanEventName, trace.WithAttributes(attrs...))
+
+ // Record sanitized error for error-tracking integrations
+ span.RecordError(fmt.Errorf("%w: %s", ErrPanic, panicStr))
+
+ // Set span status to Error
+ statusMsg := "panic recovered in " + goroutineName
+ if component != "" {
+ statusMsg = fmt.Sprintf("panic recovered in %s/%s", component, goroutineName)
+ }
+
+ span.SetStatus(codes.Error, statusMsg)
+}
diff --git a/runtime/tracing_test.go b/runtime/tracing_test.go
new file mode 100644
index 0000000..66d2d08
--- /dev/null
+++ b/runtime/tracing_test.go
@@ -0,0 +1,461 @@
+//go:build unit
+
+package runtime
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel/codes"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ "go.opentelemetry.io/otel/sdk/trace/tracetest"
+ "go.opentelemetry.io/otel/trace"
+)
+
+func newTestTracerProvider(t *testing.T) (*sdktrace.TracerProvider, *tracetest.SpanRecorder) {
+ t.Helper()
+
+ recorder := tracetest.NewSpanRecorder()
+ provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
+
+ t.Cleanup(func() {
+ _ = provider.Shutdown(context.Background())
+ })
+
+ return provider, recorder
+}
+
+func TestErrPanic(t *testing.T) {
+ t.Parallel()
+
+ assert.NotNil(t, ErrPanic)
+ assert.Equal(t, "panic", ErrPanic.Error())
+}
+
+func TestPanicSpanEventName(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, "panic.recovered", PanicSpanEventName)
+}
+
+func TestRecordPanicToSpan(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ panicValue any
+ stack []byte
+ goroutineName string
+ wantEvent bool
+ wantStatus codes.Code
+ wantMessage string
+ }{
+ {
+ name: "string panic value",
+ panicValue: "something went wrong",
+ stack: []byte("goroutine 1 [running]:\nmain.main()"),
+ goroutineName: "worker-1",
+ wantEvent: true,
+ wantStatus: codes.Error,
+ wantMessage: "panic recovered in worker-1",
+ },
+ {
+ name: "error panic value",
+ panicValue: assert.AnError,
+ stack: []byte("stack trace here"),
+ goroutineName: "handler",
+ wantEvent: true,
+ wantStatus: codes.Error,
+ wantMessage: "panic recovered in handler",
+ },
+ {
+ name: "integer panic value",
+ panicValue: 42,
+ stack: []byte(""),
+ goroutineName: "processor",
+ wantEvent: true,
+ wantStatus: codes.Error,
+ wantMessage: "panic recovered in processor",
+ },
+ {
+ name: "nil panic value",
+ panicValue: nil,
+ stack: []byte("some stack"),
+ goroutineName: "main",
+ wantEvent: true,
+ wantStatus: codes.Error,
+ wantMessage: "panic recovered in main",
+ },
+ {
+ name: "empty goroutine name",
+ panicValue: "panic!",
+ stack: []byte("trace"),
+ goroutineName: "",
+ wantEvent: true,
+ wantStatus: codes.Error,
+ wantMessage: "panic recovered in ",
+ },
+ {
+ name: "empty stack trace",
+ panicValue: "error",
+ stack: nil,
+ goroutineName: "worker",
+ wantEvent: true,
+ wantStatus: codes.Error,
+ wantMessage: "panic recovered in worker",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ provider, recorder := newTestTracerProvider(t)
+ tracer := provider.Tracer("test")
+ ctx, span := tracer.Start(context.Background(), "test-span")
+
+ RecordPanicToSpan(ctx, tt.panicValue, tt.stack, tt.goroutineName)
+ span.End()
+
+ spans := recorder.Ended()
+ require.Len(t, spans, 1)
+
+ recordedSpan := spans[0]
+
+ if tt.wantEvent {
+ require.NotEmpty(t, recordedSpan.Events(), "expected panic event to be recorded")
+
+ var foundPanicEvent bool
+
+ for _, event := range recordedSpan.Events() {
+ if event.Name == PanicSpanEventName {
+ foundPanicEvent = true
+
+ attrMap := make(map[string]string)
+ for _, attr := range event.Attributes {
+ attrMap[string(attr.Key)] = attr.Value.AsString()
+ }
+
+ assert.Contains(t, attrMap, "panic.value")
+ assert.Contains(t, attrMap, "panic.stack")
+ assert.Contains(t, attrMap, "panic.goroutine_name")
+ assert.Equal(t, tt.goroutineName, attrMap["panic.goroutine_name"])
+ assert.NotContains(
+ t,
+ attrMap,
+ "panic.component",
+ "component should not be present for RecordPanicToSpan",
+ )
+ }
+ }
+
+ assert.True(t, foundPanicEvent, "panic.recovered event not found")
+ }
+
+ assert.Equal(t, tt.wantStatus, recordedSpan.Status().Code)
+ assert.Equal(t, tt.wantMessage, recordedSpan.Status().Description)
+ })
+ }
+}
+
+func TestRecordPanicToSpanWithComponent(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ panicValue any
+ stack []byte
+ component string
+ goroutineName string
+ wantEvent bool
+ wantStatus codes.Code
+ wantMessage string
+ }{
+ {
+ name: "with component",
+ panicValue: "panic error",
+ stack: []byte("stack trace"),
+ component: "transaction",
+ goroutineName: "CreateTransaction",
+ wantEvent: true,
+ wantStatus: codes.Error,
+ wantMessage: "panic recovered in transaction/CreateTransaction",
+ },
+ {
+ name: "empty component",
+ panicValue: "error",
+ stack: []byte("trace"),
+ component: "",
+ goroutineName: "handler",
+ wantEvent: true,
+ wantStatus: codes.Error,
+ wantMessage: "panic recovered in handler",
+ },
+ {
+ name: "empty goroutine name with component",
+ panicValue: "error",
+ stack: []byte("trace"),
+ component: "auth",
+ goroutineName: "",
+ wantEvent: true,
+ wantStatus: codes.Error,
+ wantMessage: "panic recovered in auth/",
+ },
+ {
+ name: "both empty",
+ panicValue: "panic",
+ stack: []byte(""),
+ component: "",
+ goroutineName: "",
+ wantEvent: true,
+ wantStatus: codes.Error,
+ wantMessage: "panic recovered in ",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ provider, recorder := newTestTracerProvider(t)
+ tracer := provider.Tracer("test")
+ ctx, span := tracer.Start(context.Background(), "test-span")
+
+ RecordPanicToSpanWithComponent(
+ ctx,
+ tt.panicValue,
+ tt.stack,
+ tt.component,
+ tt.goroutineName,
+ )
+ span.End()
+
+ spans := recorder.Ended()
+ require.Len(t, spans, 1)
+
+ recordedSpan := spans[0]
+
+ if tt.wantEvent {
+ require.NotEmpty(t, recordedSpan.Events(), "expected panic event to be recorded")
+
+ var foundPanicEvent bool
+
+ for _, event := range recordedSpan.Events() {
+ if event.Name == PanicSpanEventName {
+ foundPanicEvent = true
+
+ attrMap := make(map[string]string)
+ for _, attr := range event.Attributes {
+ attrMap[string(attr.Key)] = attr.Value.AsString()
+ }
+
+ assert.Contains(t, attrMap, "panic.value")
+ assert.Contains(t, attrMap, "panic.stack")
+ assert.Contains(t, attrMap, "panic.goroutine_name")
+ assert.Equal(t, tt.goroutineName, attrMap["panic.goroutine_name"])
+
+ if tt.component != "" {
+ assert.Contains(t, attrMap, "panic.component")
+ assert.Equal(t, tt.component, attrMap["panic.component"])
+ }
+ }
+ }
+
+ assert.True(t, foundPanicEvent, "panic.recovered event not found")
+ }
+
+ assert.Equal(t, tt.wantStatus, recordedSpan.Status().Code)
+ assert.Equal(t, tt.wantMessage, recordedSpan.Status().Description)
+ })
+ }
+}
+
+func TestRecordPanicToSpan_NoActiveSpan(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+
+ require.NotPanics(t, func() {
+ RecordPanicToSpan(ctx, "panic value", []byte("stack"), "goroutine")
+ })
+}
+
+func TestRecordPanicToSpanWithComponent_NoActiveSpan(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+
+ require.NotPanics(t, func() {
+ RecordPanicToSpanWithComponent(
+ ctx,
+ "panic value",
+ []byte("stack"),
+ "component",
+ "goroutine",
+ )
+ })
+}
+
+func TestRecordPanicToSpan_NonRecordingSpan(t *testing.T) {
+ t.Parallel()
+
+ provider := sdktrace.NewTracerProvider()
+
+ t.Cleanup(func() {
+ _ = provider.Shutdown(context.Background())
+ })
+
+ tracer := provider.Tracer("test")
+ _, nonRecordingSpan := tracer.Start(
+ context.Background(),
+ "test-span",
+ trace.WithSpanKind(trace.SpanKindInternal),
+ )
+ nonRecordingSpan.End()
+
+ ctx := trace.ContextWithSpan(context.Background(), nonRecordingSpan)
+
+ require.NotPanics(t, func() {
+ RecordPanicToSpan(ctx, "panic value", []byte("stack"), "goroutine")
+ })
+}
+
+func TestRecordPanicToSpan_NilContext(t *testing.T) {
+ t.Parallel()
+
+ var nilCtx context.Context
+
+ require.NotPanics(t, func() {
+ RecordPanicToSpan(nilCtx, "panic value", []byte("stack"), "goroutine")
+ })
+}
+
+func TestRecordPanicToSpanWithComponent_NilContext(t *testing.T) {
+ t.Parallel()
+
+ var nilCtx context.Context
+
+ require.NotPanics(t, func() {
+ RecordPanicToSpanWithComponent(
+ nilCtx,
+ "panic value",
+ []byte("stack"),
+ "component",
+ "goroutine",
+ )
+ })
+}
+
+func TestRecordPanicToSpan_VerifyErrorRecorded(t *testing.T) {
+ t.Parallel()
+
+ provider, recorder := newTestTracerProvider(t)
+ tracer := provider.Tracer("test")
+ ctx, span := tracer.Start(context.Background(), "test-span")
+
+ panicValue := "test panic"
+ RecordPanicToSpan(ctx, panicValue, []byte("stack trace"), "worker")
+ span.End()
+
+ spans := recorder.Ended()
+ require.Len(t, spans, 1)
+
+ recordedSpan := spans[0]
+ events := recordedSpan.Events()
+
+ var (
+ hasExceptionEvent bool
+ hasPanicEvent bool
+ )
+
+ for _, event := range events {
+ if event.Name == "exception" {
+ hasExceptionEvent = true
+
+ attrMap := make(map[string]string)
+ for _, attr := range event.Attributes {
+ attrMap[string(attr.Key)] = attr.Value.AsString()
+ }
+
+ assert.Contains(t, attrMap["exception.message"], "panic")
+ assert.Contains(t, attrMap["exception.message"], panicValue)
+ }
+
+ if event.Name == PanicSpanEventName {
+ hasPanicEvent = true
+ }
+ }
+
+ assert.True(t, hasExceptionEvent, "expected exception event from RecordError")
+ assert.True(t, hasPanicEvent, "expected panic.recovered event")
+}
+
+func TestRecordPanicToSpan_ComplexPanicValues(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ panicValue any
+ wantValue string
+ }{
+ {
+ name: "struct panic value",
+ panicValue: struct{ Message string }{Message: "error"},
+ wantValue: "{error}",
+ },
+ {
+ name: "slice panic value",
+ panicValue: []string{"a", "b", "c"},
+ wantValue: "[a b c]",
+ },
+ {
+ name: "map panic value",
+ panicValue: map[string]int{"key": 1},
+ wantValue: "map[key:1]",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ provider, recorder := newTestTracerProvider(t)
+ tracer := provider.Tracer("test")
+ ctx, span := tracer.Start(context.Background(), "test-span")
+
+ RecordPanicToSpan(ctx, tt.panicValue, []byte("stack"), "goroutine")
+ span.End()
+
+ spans := recorder.Ended()
+ require.Len(t, spans, 1)
+
+ recordedSpan := spans[0]
+
+ var panicEvent *sdktrace.Event
+
+ for i := range recordedSpan.Events() {
+ if recordedSpan.Events()[i].Name == PanicSpanEventName {
+ panicEvent = &recordedSpan.Events()[i]
+
+ break
+ }
+ }
+
+ require.NotNil(t, panicEvent)
+
+ var panicValueAttr string
+
+ for _, attr := range panicEvent.Attributes {
+ if string(attr.Key) == "panic.value" {
+ panicValueAttr = attr.Value.AsString()
+
+ break
+ }
+ }
+
+ assert.Equal(t, tt.wantValue, panicValueAttr)
+ })
+ }
+}
diff --git a/scripts/makefile_colors.mk b/scripts/makefile_colors.mk
new file mode 100644
index 0000000..7edc0e5
--- /dev/null
+++ b/scripts/makefile_colors.mk
@@ -0,0 +1,14 @@
+# Colors and formatting for Makefiles
+# This file contains standardized color and formatting definitions
+# to be included in all component Makefiles
+
+# ANSI color codes
+BLUE := \033[34m
+NC := \033[0m
+BOLD := \033[1m
+RED := \033[31m
+MAGENTA := \033[35m
+YELLOW := \033[33m
+GREEN := \033[32m
+CYAN := \033[36m
+WHITE := \033[37m
diff --git a/scripts/makefile_utils.mk b/scripts/makefile_utils.mk
new file mode 100644
index 0000000..3fa01fe
--- /dev/null
+++ b/scripts/makefile_utils.mk
@@ -0,0 +1,11 @@
+# Makefile utility functions for lib-observability
+# Included by the root Makefile
+
+# Check that a command exists, or print install instructions and exit.
+# Usage: $(call check_command,,)
+define check_command
+ @command -v $(1) >/dev/null 2>&1 || { \
+ echo "$(RED)$(BOLD)Error:$(NC) '$(1)' is not installed. $(2)"; \
+ exit 1; \
+ }
+endef
diff --git a/system.go b/system.go
new file mode 100644
index 0000000..155adf8
--- /dev/null
+++ b/system.go
@@ -0,0 +1,60 @@
+package observability
+
+import (
+ "context"
+ "time"
+
+ "github.com/LerianStudio/lib-observability/log"
+ "github.com/LerianStudio/lib-observability/metrics"
+ "github.com/shirou/gopsutil/cpu"
+ "github.com/shirou/gopsutil/mem"
+)
+
+// GetCPUUsage reads the current CPU usage and records it through the MetricsFactory gauge.
+// If factory is nil, the reading is performed but metric recording is skipped.
+func GetCPUUsage(ctx context.Context, factory *metrics.MetricsFactory) {
+ logger := NewLoggerFromContext(ctx)
+
+ out, err := cpu.Percent(100*time.Millisecond, false)
+ if err != nil {
+ logger.Log(ctx, log.LevelWarn, "error getting CPU usage", log.Err(err))
+ return
+ }
+
+ var percentageCPU int64 = 0
+ if len(out) > 0 {
+ percentageCPU = int64(out[0])
+ }
+
+ if factory == nil {
+ logger.Log(ctx, log.LevelWarn, "metrics factory is nil, skipping CPU usage recording")
+ return
+ }
+
+ if err := factory.RecordSystemCPUUsage(ctx, percentageCPU); err != nil {
+ logger.Log(ctx, log.LevelWarn, "error recording CPU gauge", log.Err(err))
+ }
+}
+
+// GetMemUsage reads the current memory usage and records it through the MetricsFactory gauge.
+// If factory is nil, the reading is performed but metric recording is skipped.
+func GetMemUsage(ctx context.Context, factory *metrics.MetricsFactory) {
+ logger := NewLoggerFromContext(ctx)
+
+ out, err := mem.VirtualMemory()
+ if err != nil {
+ logger.Log(ctx, log.LevelWarn, "error getting memory info", log.Err(err))
+ return
+ }
+
+ percentageMem := int64(out.UsedPercent)
+
+ if factory == nil {
+ logger.Log(ctx, log.LevelWarn, "metrics factory is nil, skipping memory usage recording")
+ return
+ }
+
+ if err := factory.RecordSystemMemUsage(ctx, percentageMem); err != nil {
+ logger.Log(ctx, log.LevelWarn, "error recording memory gauge", log.Err(err))
+ }
+}
diff --git a/system_test.go b/system_test.go
new file mode 100644
index 0000000..6970981
--- /dev/null
+++ b/system_test.go
@@ -0,0 +1,21 @@
+//go:build unit
+
+package observability
+
+import (
+ "context"
+ "testing"
+
+ "github.com/LerianStudio/lib-observability/metrics"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSystemUsageHelpers(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ assert.NotPanics(t, func() { GetCPUUsage(ctx, nil) })
+ assert.NotPanics(t, func() { GetMemUsage(ctx, nil) })
+ assert.NotPanics(t, func() { GetCPUUsage(ctx, metrics.NewNopFactory()) })
+ assert.NotPanics(t, func() { GetMemUsage(ctx, metrics.NewNopFactory()) })
+}
diff --git a/tracing/doc.go b/tracing/doc.go
new file mode 100644
index 0000000..61b4923
--- /dev/null
+++ b/tracing/doc.go
@@ -0,0 +1,5 @@
+// Package tracing provides OpenTelemetry SDK lifecycle management, trace context
+// propagation for HTTP/gRPC/message queues, span error and event recording helpers,
+// struct-to-attribute conversion with sensitive field redaction, and custom
+// SpanProcessor implementations.
+package tracing
diff --git a/tracing/obfuscation.go b/tracing/obfuscation.go
new file mode 100644
index 0000000..ab99b25
--- /dev/null
+++ b/tracing/obfuscation.go
@@ -0,0 +1,264 @@
+package tracing
+
+import (
+ "bytes"
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "regexp"
+
+ cn "github.com/LerianStudio/lib-observability/constants"
+ "github.com/LerianStudio/lib-observability/redaction"
+)
+
+// RedactionAction defines how sensitive values are transformed.
+type RedactionAction string
+
+const (
+ // RedactionMask replaces a sensitive value with the configured mask.
+ RedactionMask RedactionAction = "mask"
+ // RedactionHash replaces a sensitive value with an HMAC-SHA256 hash.
+ RedactionHash RedactionAction = "hash"
+ // RedactionDrop removes a sensitive field from the output.
+ RedactionDrop RedactionAction = "drop"
+)
+
+// RedactionRule matches fields/paths and applies a redaction action.
+type RedactionRule struct {
+ FieldPattern string
+ PathPattern string
+ Action RedactionAction
+
+ fieldRegex *regexp.Regexp
+ pathRegex *regexp.Regexp
+}
+
+// hmacKeySize is the byte length of the HMAC key generated for each Redactor.
+const hmacKeySize = 32
+
+// Redactor applies ordered redaction rules to structured payloads.
+type Redactor struct {
+ rules []RedactionRule
+ maskValue string
+ hmacKey []byte // per-instance key used by HMAC-SHA256 hashing
+}
+
+// NewDefaultRedactor builds a mask-based redactor from default sensitive fields.
+func NewDefaultRedactor() *Redactor {
+ fields := redaction.DefaultSensitiveFields()
+
+ rules := make([]RedactionRule, 0, len(fields))
+ for _, field := range fields {
+ rules = append(rules, RedactionRule{FieldPattern: `(?i)^` + regexp.QuoteMeta(field) + `$`, Action: RedactionMask})
+ }
+
+ r, err := NewRedactor(rules, cn.ObfuscatedValue)
+ if err != nil {
+ // Rule compilation failed unexpectedly. Return a conservative always-mask
+ // redactor rather than a no-rules redactor that would leak everything.
+ return NewAlwaysMaskRedactor()
+ }
+
+ return r
+}
+
+// NewAlwaysMaskRedactor returns a conservative redactor that treats ALL fields as sensitive.
+// This is used as a safe fallback when normal redactor construction fails, ensuring
+// no data leaks through in fail-open scenarios.
+func NewAlwaysMaskRedactor() *Redactor {
+ return &Redactor{
+ rules: []RedactionRule{
+ {
+ // Match every field name
+ FieldPattern: ".*",
+ fieldRegex: regexp.MustCompile(".*"),
+ Action: RedactionMask,
+ },
+ },
+ maskValue: cn.ObfuscatedValue,
+ }
+}
+
+// NewRedactor compiles rules and returns a configured redactor.
+func NewRedactor(rules []RedactionRule, maskValue string) (*Redactor, error) {
+ if maskValue == "" {
+ maskValue = cn.ObfuscatedValue
+ }
+
+ compiled := make([]RedactionRule, 0, len(rules))
+ for i := range rules {
+ rule := rules[i]
+ if rule.Action == "" {
+ rule.Action = RedactionMask
+ }
+
+ if rule.FieldPattern != "" {
+ re, err := regexp.Compile(rule.FieldPattern)
+ if err != nil {
+ return nil, fmt.Errorf("invalid redaction field pattern at index %d: %w", i, err)
+ }
+
+ rule.fieldRegex = re
+ }
+
+ if rule.PathPattern != "" {
+ re, err := regexp.Compile(rule.PathPattern)
+ if err != nil {
+ return nil, fmt.Errorf("invalid redaction path pattern at index %d: %w", i, err)
+ }
+
+ rule.pathRegex = re
+ }
+
+ compiled = append(compiled, rule)
+ }
+
+ key := make([]byte, hmacKeySize)
+ if _, err := rand.Read(key); err != nil {
+ return nil, fmt.Errorf("failed to generate HMAC key: %w", err)
+ }
+
+ return &Redactor{rules: compiled, maskValue: maskValue, hmacKey: key}, nil
+}
+
+func (r *Redactor) actionFor(path, fieldName string) (RedactionAction, bool) {
+ if r == nil {
+ return "", false
+ }
+
+ for i := range r.rules {
+ rule := r.rules[i]
+ pathMatch := true
+
+ var fieldMatch bool
+ if rule.fieldRegex != nil {
+ fieldMatch = rule.fieldRegex.MatchString(fieldName)
+ } else {
+ fieldMatch = redaction.IsSensitiveField(fieldName)
+ }
+
+ if rule.pathRegex != nil {
+ pathMatch = rule.pathRegex.MatchString(path)
+ }
+
+ if fieldMatch && pathMatch {
+ return rule.Action, true
+ }
+ }
+
+ return "", false
+}
+
+// redactValue applies the first matching redaction rule to a field value.
+// It returns the (possibly transformed) value, whether the field should be dropped,
+// and whether any redaction rule was applied (so the caller can skip expensive comparison).
+func (r *Redactor) redactValue(path, fieldName string, value any) (redacted any, drop bool, applied bool) {
+ action, ok := r.actionFor(path, fieldName)
+ if !ok {
+ return value, false, false
+ }
+
+ switch action {
+ case RedactionDrop:
+ return nil, true, true
+ case RedactionHash:
+ return r.hashString(fmt.Sprint(value)), false, true
+ case RedactionMask:
+ fallthrough
+ default:
+ return r.maskValue, false, true
+ }
+}
+
+// hashString computes an HMAC-SHA256 of v using the Redactor's per-instance key.
+// The result is a hex-encoded string prefixed with "sha256:" for identification.
+// Using HMAC prevents rainbow-table attacks against low-entropy PII.
+func (r *Redactor) hashString(v string) string {
+ if len(r.hmacKey) > 0 {
+ mac := hmac.New(sha256.New, r.hmacKey)
+ mac.Write([]byte(v))
+
+ return "sha256:" + hex.EncodeToString(mac.Sum(nil))
+ }
+
+ // Fallback for zero-key edge case (should not happen with proper construction).
+ h := sha256.Sum256([]byte(v))
+
+ return fmt.Sprintf("sha256:%x", h)
+}
+
+// obfuscateStructFields recursively obfuscates sensitive fields in a struct or map.
+func obfuscateStructFields(data any, path string, r *Redactor) any {
+ switch v := data.(type) {
+ case map[string]any:
+ result := make(map[string]any, len(v))
+
+ for key, value := range v {
+ childPath := key
+ if path != "" {
+ childPath = path + "." + key
+ }
+
+ if r != nil {
+ redacted, drop, applied := r.redactValue(childPath, key, value)
+ if drop {
+ continue
+ }
+
+ if applied {
+ result[key] = redacted
+ continue
+ }
+ }
+
+ result[key] = obfuscateStructFields(value, childPath, r)
+ }
+
+ return result
+
+ case []any:
+ result := make([]any, len(v))
+
+ for i, item := range v {
+ childPath := fmt.Sprintf("%s[%d]", path, i)
+ result[i] = obfuscateStructFields(item, childPath, r)
+ }
+
+ return result
+
+ default:
+ return data
+ }
+}
+
+// ObfuscateStruct applies obfuscation to a struct and returns the obfuscated data.
+// This is a utility function that can be used independently of OpenTelemetry spans.
+func ObfuscateStruct(valueStruct any, r *Redactor) (any, error) {
+ if valueStruct == nil || r == nil {
+ return valueStruct, nil
+ }
+
+ // Convert to JSON and back to get a generic representation.
+ // Using any (not map[string]any) to handle arrays, primitives, and objects.
+ jsonBytes, err := json.Marshal(valueStruct)
+ if err != nil {
+ return nil, err
+ }
+
+ var data any
+
+ decoder := json.NewDecoder(bytes.NewReader(jsonBytes))
+ decoder.UseNumber()
+
+ if err := decoder.Decode(&data); err != nil {
+ return nil, err
+ }
+
+ // Zero the intermediate buffer to minimize sensitive data lifetime in memory
+ clear(jsonBytes)
+
+ return obfuscateStructFields(data, "", r), nil
+}
diff --git a/tracing/obfuscation_test.go b/tracing/obfuscation_test.go
new file mode 100644
index 0000000..90477d0
--- /dev/null
+++ b/tracing/obfuscation_test.go
@@ -0,0 +1,1042 @@
+//go:build unit
+
+package tracing
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+ "testing"
+
+ constant "github.com/LerianStudio/lib-observability/constants"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel/attribute"
+)
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+// mustRedactor builds a Redactor or fails the test.
+func mustRedactor(t *testing.T, rules []RedactionRule, mask string) *Redactor {
+ t.Helper()
+
+ r, err := NewRedactor(rules, mask)
+ require.NoError(t, err)
+
+ return r
+}
+
+// hashVia returns the HMAC-SHA256 hash of v using the given redactor's key.
+func hashVia(r *Redactor, v string) string {
+ return r.hashString(v)
+}
+
+// ===========================================================================
+// 1. Redactor construction
+// ===========================================================================
+
+func TestNewRedactor_EmptyRules(t *testing.T) {
+ t.Parallel()
+
+ r, err := NewRedactor(nil, "")
+ require.NoError(t, err)
+ require.NotNil(t, r)
+ assert.Empty(t, r.rules)
+ assert.Equal(t, constant.ObfuscatedValue, r.maskValue, "empty mask should fall back to constant")
+}
+
+func TestNewRedactor_CustomMaskValue(t *testing.T) {
+ t.Parallel()
+
+ r, err := NewRedactor(nil, "REDACTED")
+ require.NoError(t, err)
+ assert.Equal(t, "REDACTED", r.maskValue)
+}
+
+func TestNewRedactor_DefaultActionIsMask(t *testing.T) {
+ t.Parallel()
+
+ r, err := NewRedactor([]RedactionRule{
+ {FieldPattern: `^foo$`},
+ }, "")
+ require.NoError(t, err)
+ require.Len(t, r.rules, 1)
+ assert.Equal(t, RedactionMask, r.rules[0].Action, "blank Action should default to mask")
+}
+
+func TestNewRedactor_InvalidFieldPattern(t *testing.T) {
+ t.Parallel()
+
+ _, err := NewRedactor([]RedactionRule{
+ {FieldPattern: `[invalid`},
+ }, "")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid redaction field pattern at index 0")
+}
+
+func TestNewRedactor_InvalidPathPattern(t *testing.T) {
+ t.Parallel()
+
+ _, err := NewRedactor([]RedactionRule{
+ {PathPattern: `[broken`},
+ }, "")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid redaction path pattern at index 0")
+}
+
+func TestNewRedactor_MultipleRulesCompileCorrectly(t *testing.T) {
+ t.Parallel()
+
+ r, err := NewRedactor([]RedactionRule{
+ {FieldPattern: `^password$`, Action: RedactionMask},
+ {FieldPattern: `^email$`, Action: RedactionHash},
+ {PathPattern: `^session\.token$`, FieldPattern: `^token$`, Action: RedactionDrop},
+ }, "***")
+ require.NoError(t, err)
+ require.Len(t, r.rules, 3)
+ assert.NotNil(t, r.rules[0].fieldRegex)
+ assert.Nil(t, r.rules[0].pathRegex)
+ assert.NotNil(t, r.rules[2].fieldRegex)
+ assert.NotNil(t, r.rules[2].pathRegex)
+}
+
+// ===========================================================================
+// 2. NewDefaultRedactor
+// ===========================================================================
+
+func TestNewDefaultRedactor_IsNotNil(t *testing.T) {
+ t.Parallel()
+
+ r := NewDefaultRedactor()
+ require.NotNil(t, r)
+ assert.NotEmpty(t, r.rules, "default redactor should have rules from DefaultSensitiveFields")
+ assert.Equal(t, constant.ObfuscatedValue, r.maskValue)
+}
+
+func TestNewDefaultRedactor_MatchesSensitiveFields(t *testing.T) {
+ t.Parallel()
+
+ r := NewDefaultRedactor()
+
+ for _, field := range []string{"password", "token", "secret", "authorization", "apikey", "cvv", "ssn"} {
+ action, matched := r.actionFor("", field)
+ assert.True(t, matched, "field %q should match default rules", field)
+ assert.Equal(t, RedactionMask, action)
+ }
+}
+
+func TestNewDefaultRedactor_CaseInsensitive(t *testing.T) {
+ t.Parallel()
+
+ r := NewDefaultRedactor()
+
+ for _, field := range []string{"Password", "PASSWORD", "pAsSwOrD"} {
+ _, matched := r.actionFor("", field)
+ assert.True(t, matched, "field %q should match case-insensitively", field)
+ }
+}
+
+func TestNewDefaultRedactor_NonSensitiveFieldUnchanged(t *testing.T) {
+ t.Parallel()
+
+ r := NewDefaultRedactor()
+
+ _, matched := r.actionFor("", "username")
+ assert.False(t, matched)
+}
+
+// ===========================================================================
+// 3. actionFor (field and path matching)
+// ===========================================================================
+
+func TestActionFor_NilRedactor(t *testing.T) {
+ t.Parallel()
+
+ var r *Redactor
+
+ action, matched := r.actionFor("any.path", "any")
+ assert.False(t, matched)
+ assert.Equal(t, RedactionAction(""), action)
+}
+
+func TestActionFor_ExactFieldMatch(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `^email$`, Action: RedactionHash},
+ }, "")
+
+ action, ok := r.actionFor("user.email", "email")
+ assert.True(t, ok)
+ assert.Equal(t, RedactionHash, action)
+}
+
+func TestActionFor_RegexFieldPattern(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i).*password.*`, Action: RedactionMask},
+ }, "")
+
+ tests := []struct {
+ field string
+ matched bool
+ }{
+ {"password", true},
+ {"user_password", true},
+ {"password_hash", true},
+ {"newPassword", true},
+ {"username", false},
+ }
+
+ for _, tt := range tests {
+ action, ok := r.actionFor("", tt.field)
+ assert.Equal(t, tt.matched, ok, "field=%q", tt.field)
+
+ if tt.matched {
+ assert.Equal(t, RedactionMask, action)
+ }
+ }
+}
+
+func TestActionFor_PathPatternOnly(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {PathPattern: `^config\.db\.password$`, Action: RedactionDrop},
+ }, "")
+
+ _, ok := r.actionFor("config.db.password", "password")
+ assert.True(t, ok, "path+field should match")
+
+ _, ok = r.actionFor("user.password", "password")
+ assert.False(t, ok, "path should NOT match different prefix")
+}
+
+func TestActionFor_CombinedFieldAndPathPattern(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `^token$`, PathPattern: `^session\.`, Action: RedactionDrop},
+ }, "")
+
+ _, ok := r.actionFor("session.token", "token")
+ assert.True(t, ok)
+
+ _, ok = r.actionFor("auth.token", "token")
+ assert.False(t, ok)
+}
+
+func TestActionFor_NoMatch(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `^secret$`, Action: RedactionMask},
+ }, "")
+
+ _, ok := r.actionFor("", "name")
+ assert.False(t, ok)
+}
+
+func TestActionFor_FirstMatchWins(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^password$`, Action: RedactionHash},
+ {FieldPattern: `(?i)^password$`, Action: RedactionDrop},
+ }, "")
+
+ action, ok := r.actionFor("", "password")
+ assert.True(t, ok)
+ assert.Equal(t, RedactionHash, action, "first matching rule should win")
+}
+
+// ===========================================================================
+// 4. redactValue (mask / hash / drop)
+// ===========================================================================
+
+func TestRedactValue_Mask(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^password$`, Action: RedactionMask},
+ }, "***")
+
+ val, drop, applied := r.redactValue("", "password", "secret123")
+ assert.False(t, drop)
+ assert.True(t, applied)
+ assert.Equal(t, "***", val)
+}
+
+func TestRedactValue_Hash(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^email$`, Action: RedactionHash},
+ }, "")
+
+ val, drop, applied := r.redactValue("", "email", "alice@example.com")
+ assert.False(t, drop)
+ assert.True(t, applied)
+ assert.Equal(t, hashVia(r, "alice@example.com"), val)
+}
+
+func TestRedactValue_Hash_Deterministic(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^document$`, Action: RedactionHash},
+ }, "")
+
+ val1, _, _ := r.redactValue("", "document", "12345")
+ val2, _, _ := r.redactValue("", "document", "12345")
+ assert.Equal(t, val1, val2, "hashing the same value must be deterministic")
+}
+
+func TestRedactValue_Hash_DifferentInputs(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^document$`, Action: RedactionHash},
+ }, "")
+
+ val1, _, _ := r.redactValue("", "document", "abc")
+ val2, _, _ := r.redactValue("", "document", "def")
+ assert.NotEqual(t, val1, val2, "different inputs must produce different hashes")
+}
+
+func TestRedactValue_Drop(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^token$`, Action: RedactionDrop},
+ }, "")
+
+ val, drop, applied := r.redactValue("", "token", "tok_abc")
+ assert.True(t, drop)
+ assert.True(t, applied)
+ assert.Nil(t, val)
+}
+
+func TestRedactValue_NoMatch(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `^secret$`, Action: RedactionMask},
+ }, "")
+
+ val, drop, applied := r.redactValue("", "name", "Alice")
+ assert.False(t, drop)
+ assert.False(t, applied)
+ assert.Equal(t, "Alice", val)
+}
+
+func TestRedactValue_NilRedactor(t *testing.T) {
+ t.Parallel()
+
+ var r *Redactor
+
+ val, drop, applied := r.redactValue("", "password", "secret")
+ assert.False(t, drop)
+ assert.False(t, applied)
+ assert.Equal(t, "secret", val)
+}
+
+// ===========================================================================
+// 5. hashString
+// ===========================================================================
+
+func TestHashString_Deterministic(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, nil, "")
+
+ h1 := r.hashString("hello")
+ h2 := r.hashString("hello")
+ assert.Equal(t, h1, h2)
+}
+
+func TestHashString_DifferentInputs(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, nil, "")
+
+ h1 := r.hashString("foo")
+ h2 := r.hashString("bar")
+ assert.NotEqual(t, h1, h2)
+}
+
+func TestHashString_Empty(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, nil, "")
+
+ h := r.hashString("")
+ assert.NotEmpty(t, h, "hash of empty string should produce a non-empty output")
+ assert.True(t, strings.HasPrefix(h, "sha256:"), "hash should have sha256: prefix")
+}
+
+func TestHashString_DifferentRedactorsProduceDifferentHashes(t *testing.T) {
+ t.Parallel()
+
+ r1 := mustRedactor(t, nil, "")
+ r2 := mustRedactor(t, nil, "")
+
+ h1 := r1.hashString("same-input")
+ h2 := r2.hashString("same-input")
+ assert.NotEqual(t, h1, h2, "different Redactors use different HMAC keys and should produce different hashes")
+}
+
+// ===========================================================================
+// 6. obfuscateStructFields -- flat maps
+// ===========================================================================
+
+func TestObfuscateStructFields_FlatMap(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^password$`, Action: RedactionMask},
+ {FieldPattern: `(?i)^email$`, Action: RedactionHash},
+ }, "***")
+
+ input := map[string]any{
+ "name": "alice",
+ "email": "alice@example.com",
+ "password": "secret",
+ }
+
+ result := obfuscateStructFields(input, "", r)
+ m, ok := result.(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "alice", m["name"])
+ assert.Equal(t, "***", m["password"])
+ assert.Equal(t, hashVia(r, "alice@example.com"), m["email"])
+}
+
+func TestObfuscateStructFields_EmptyMap(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `^password$`, Action: RedactionMask},
+ }, "***")
+
+ result := obfuscateStructFields(map[string]any{}, "", r)
+ m, ok := result.(map[string]any)
+ require.True(t, ok)
+ assert.Empty(t, m)
+}
+
+func TestObfuscateStructFields_NilRedactor(t *testing.T) {
+ t.Parallel()
+
+ input := map[string]any{
+ "password": "secret",
+ }
+
+ result := obfuscateStructFields(input, "", nil)
+ m := result.(map[string]any)
+ assert.Equal(t, "secret", m["password"], "nil redactor should pass values through")
+}
+
+// ===========================================================================
+// 7. obfuscateStructFields -- nested maps
+// ===========================================================================
+
+func TestObfuscateStructFields_NestedTwoLevels(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^password$`, Action: RedactionMask},
+ }, "***")
+
+ input := map[string]any{
+ "user": map[string]any{
+ "name": "bob",
+ "password": "topsecret",
+ },
+ }
+
+ result := obfuscateStructFields(input, "", r).(map[string]any)
+ user := result["user"].(map[string]any)
+ assert.Equal(t, "bob", user["name"])
+ assert.Equal(t, "***", user["password"])
+}
+
+func TestObfuscateStructFields_NestedThreeLevels(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^secret$`, Action: RedactionDrop},
+ }, "")
+
+ input := map[string]any{
+ "level1": map[string]any{
+ "level2": map[string]any{
+ "secret": "deep-value",
+ "visible": "ok",
+ },
+ },
+ }
+
+ result := obfuscateStructFields(input, "", r).(map[string]any)
+ l2 := result["level1"].(map[string]any)["level2"].(map[string]any)
+ assert.NotContains(t, l2, "secret")
+ assert.Equal(t, "ok", l2["visible"])
+}
+
+func TestObfuscateStructFields_NestedPathPattern(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {PathPattern: `^config\.database\.password$`, FieldPattern: `(?i)^password$`, Action: RedactionMask},
+ }, "HIDDEN")
+
+ input := map[string]any{
+ "config": map[string]any{
+ "database": map[string]any{
+ "password": "pg_pass",
+ "host": "localhost",
+ },
+ },
+ "password": "top-level-pass",
+ }
+
+ result := obfuscateStructFields(input, "", r).(map[string]any)
+
+ dbCfg := result["config"].(map[string]any)["database"].(map[string]any)
+ assert.Equal(t, "HIDDEN", dbCfg["password"])
+ assert.Equal(t, "localhost", dbCfg["host"])
+
+ assert.NotEqual(t, "HIDDEN", result["password"], "top-level password should NOT match path-scoped rule")
+}
+
+// ===========================================================================
+// 8. obfuscateStructFields -- arrays
+// ===========================================================================
+
+func TestObfuscateStructFields_ArrayOfObjects(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^token$`, Action: RedactionDrop},
+ }, "")
+
+ input := []any{
+ map[string]any{"id": "1", "token": "tok_a"},
+ map[string]any{"id": "2", "token": "tok_b"},
+ }
+
+ result := obfuscateStructFields(input, "", r).([]any)
+ require.Len(t, result, 2)
+
+ for i, item := range result {
+ m := item.(map[string]any)
+ assert.Equal(t, strconv.Itoa(i+1), m["id"])
+ assert.NotContains(t, m, "token")
+ }
+}
+
+func TestObfuscateStructFields_NestedArray(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^password$`, Action: RedactionMask},
+ }, "***")
+
+ input := map[string]any{
+ "users": []any{
+ map[string]any{"name": "alice", "password": "s1"},
+ map[string]any{"name": "bob", "password": "s2"},
+ },
+ }
+
+ result := obfuscateStructFields(input, "", r).(map[string]any)
+ users := result["users"].([]any)
+ require.Len(t, users, 2)
+
+ assert.Equal(t, "***", users[0].(map[string]any)["password"])
+ assert.Equal(t, "***", users[1].(map[string]any)["password"])
+ assert.Equal(t, "alice", users[0].(map[string]any)["name"])
+ assert.Equal(t, "bob", users[1].(map[string]any)["name"])
+}
+
+func TestObfuscateStructFields_EmptyArray(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, nil, "")
+
+ result := obfuscateStructFields([]any{}, "", r).([]any)
+ assert.Empty(t, result)
+}
+
+// ===========================================================================
+// 9. obfuscateStructFields -- mixed types
+// ===========================================================================
+
+func TestObfuscateStructFields_MixedTypes(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^secret$`, Action: RedactionMask},
+ }, "***")
+
+ input := map[string]any{
+ "count": float64(42),
+ "active": true,
+ "secret": "classified",
+ "nothing": nil,
+ "name": "test",
+ }
+
+ result := obfuscateStructFields(input, "", r).(map[string]any)
+ assert.Equal(t, float64(42), result["count"])
+ assert.Equal(t, true, result["active"])
+ assert.Equal(t, "***", result["secret"])
+ assert.Nil(t, result["nothing"])
+ assert.Equal(t, "test", result["name"])
+}
+
+func TestObfuscateStructFields_NilValue(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^password$`, Action: RedactionMask},
+ }, "***")
+
+ input := map[string]any{
+ "password": nil,
+ }
+
+ result := obfuscateStructFields(input, "", r).(map[string]any)
+ assert.Equal(t, "***", result["password"])
+}
+
+func TestObfuscateStructFields_EmptyStringValue(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^password$`, Action: RedactionMask},
+ }, "***")
+
+ input := map[string]any{
+ "password": "",
+ }
+
+ result := obfuscateStructFields(input, "", r).(map[string]any)
+ assert.Equal(t, "***", result["password"])
+}
+
+func TestObfuscateStructFields_NonMapNonArray(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, nil, "")
+
+ assert.Equal(t, "hello", obfuscateStructFields("hello", "", r))
+ assert.Equal(t, float64(42), obfuscateStructFields(float64(42), "", r))
+ assert.Equal(t, true, obfuscateStructFields(true, "", r))
+ assert.Nil(t, obfuscateStructFields(nil, "", r))
+}
+
+// ===========================================================================
+// 10. ObfuscateStruct (public API)
+// ===========================================================================
+
+func TestObfuscateStruct_NilInput(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, nil, "")
+
+ result, err := ObfuscateStruct(nil, r)
+ require.NoError(t, err)
+ assert.Nil(t, result)
+}
+
+func TestObfuscateStruct_NilRedactor(t *testing.T) {
+ t.Parallel()
+
+ input := map[string]any{"password": "secret"}
+
+ result, err := ObfuscateStruct(input, nil)
+ require.NoError(t, err)
+ assert.Equal(t, input, result, "nil redactor returns input unchanged")
+}
+
+func TestObfuscateStruct_BothNil(t *testing.T) {
+ t.Parallel()
+
+ result, err := ObfuscateStruct(nil, nil)
+ require.NoError(t, err)
+ assert.Nil(t, result)
+}
+
+func TestObfuscateStruct_FlatMap(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^password$`, Action: RedactionMask},
+ }, "***")
+
+ input := map[string]any{
+ "user": "alice",
+ "password": "s3cr3t",
+ }
+
+ result, err := ObfuscateStruct(input, r)
+ require.NoError(t, err)
+
+ m := result.(map[string]any)
+ assert.Equal(t, "alice", m["user"])
+ assert.Equal(t, "***", m["password"])
+}
+
+func TestObfuscateStruct_UnmarshalableInput(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, nil, "")
+
+ _, err := ObfuscateStruct(make(chan int), r)
+ require.Error(t, err)
+}
+
+// ===========================================================================
+// 11. JSON round-trip tests
+// ===========================================================================
+
+func TestJSONRoundTrip_SimplePayload(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^password$`, Action: RedactionMask},
+ {FieldPattern: `(?i)^email$`, Action: RedactionHash},
+ }, "***")
+
+ jsonInput := `{"name":"alice","email":"alice@b.com","password":"pass"}`
+
+ var parsed map[string]any
+ require.NoError(t, json.Unmarshal([]byte(jsonInput), &parsed))
+
+ result, err := ObfuscateStruct(parsed, r)
+ require.NoError(t, err)
+
+ b, err := json.Marshal(result)
+ require.NoError(t, err)
+
+ var decoded map[string]any
+ require.NoError(t, json.Unmarshal(b, &decoded))
+
+ assert.Equal(t, "alice", decoded["name"])
+ assert.Equal(t, "***", decoded["password"])
+ assert.Contains(t, decoded["email"].(string), "sha256:")
+}
+
+func TestJSONRoundTrip_EmptyJSON(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, nil, "")
+
+ var parsed map[string]any
+ require.NoError(t, json.Unmarshal([]byte(`{}`), &parsed))
+
+ result, err := ObfuscateStruct(parsed, r)
+ require.NoError(t, err)
+
+ b, err := json.Marshal(result)
+ require.NoError(t, err)
+ assert.Equal(t, "{}", string(b))
+}
+
+func TestJSONRoundTrip_LargePayload(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^password$`, Action: RedactionMask},
+ }, "***")
+
+ payload := make(map[string]any, 200)
+ for i := range 200 {
+ key := fmt.Sprintf("field_%d", i)
+ payload[key] = fmt.Sprintf("value_%d", i)
+ }
+ payload["password"] = "should_be_masked"
+
+ result, err := ObfuscateStruct(payload, r)
+ require.NoError(t, err)
+
+ m := result.(map[string]any)
+ assert.Equal(t, "***", m["password"])
+ assert.Equal(t, "value_0", m["field_0"])
+ assert.Equal(t, "value_199", m["field_199"])
+}
+
+// ===========================================================================
+// 12. All three actions end-to-end through ObfuscateStruct
+// ===========================================================================
+
+func TestObfuscateStruct_AllActionsEndToEnd(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^password$`, Action: RedactionMask},
+ {FieldPattern: `(?i)^document$`, Action: RedactionHash},
+ {FieldPattern: `(?i)^token$`, PathPattern: `^session\.token$`, Action: RedactionDrop},
+ }, "***")
+
+ input := map[string]any{
+ "password": "secret",
+ "document": "123456789",
+ "session": map[string]any{"token": "tok_abc"},
+ "name": "visible",
+ }
+
+ result, err := ObfuscateStruct(input, r)
+ require.NoError(t, err)
+
+ m := result.(map[string]any)
+
+ assert.Equal(t, "***", m["password"])
+
+ hashed, ok := m["document"].(string)
+ require.True(t, ok)
+ assert.True(t, strings.HasPrefix(hashed, "sha256:"))
+ assert.NotEqual(t, "123456789", hashed)
+
+ session, ok := m["session"].(map[string]any)
+ require.True(t, ok)
+ assert.NotContains(t, session, "token")
+
+ assert.Equal(t, "visible", m["name"])
+}
+
+// ===========================================================================
+// 13. Edge cases
+// ===========================================================================
+
+func TestObfuscateStruct_NumericValues(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^pin$`, Action: RedactionMask},
+ }, "***")
+
+ input := map[string]any{
+ "pin": float64(1234),
+ "count": float64(10),
+ }
+
+ result, err := ObfuscateStruct(input, r)
+ require.NoError(t, err)
+
+ m := result.(map[string]any)
+ assert.Equal(t, "***", m["pin"])
+ assert.Equal(t, json.Number("10"), m["count"])
+}
+
+// ===========================================================================
+// 14. Processor: redactAttributesByKey
+// ===========================================================================
+
+func TestRedactAttributesByKey_NilRedactor(t *testing.T) {
+ t.Parallel()
+
+ attrs := []attribute.KeyValue{
+ attribute.String("foo", "bar"),
+ }
+
+ result := redactAttributesByKey(attrs, nil)
+ assert.Equal(t, attrs, result, "nil redactor returns attributes unchanged")
+}
+
+func TestRedactAttributesByKey_EmptyAttrs(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `^password$`, Action: RedactionMask},
+ }, "***")
+
+ result := redactAttributesByKey(nil, r)
+ assert.Empty(t, result)
+}
+
+func TestRedactAttributesByKey_DottedKeyExtractsFieldName(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^password$`, Action: RedactionMask},
+ }, "***")
+
+ attrs := []attribute.KeyValue{
+ attribute.String("user.password", "secret"),
+ attribute.String("user.name", "alice"),
+ }
+
+ result := redactAttributesByKey(attrs, r)
+ values := make(map[string]string, len(result))
+ for _, a := range result {
+ values[string(a.Key)] = a.Value.AsString()
+ }
+
+ assert.Equal(t, "***", values["user.password"])
+ assert.Equal(t, "alice", values["user.name"])
+}
+
+func TestRedactAttributesByKey_DropRemovesAttribute(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^token$`, Action: RedactionDrop},
+ }, "")
+
+ attrs := []attribute.KeyValue{
+ attribute.String("auth.token", "tok_123"),
+ attribute.String("auth.type", "bearer"),
+ }
+
+ result := redactAttributesByKey(attrs, r)
+ require.Len(t, result, 1)
+ assert.Equal(t, "auth.type", string(result[0].Key))
+}
+
+func TestRedactAttributesByKey_HashProducesConsistentOutput(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^document$`, Action: RedactionHash},
+ }, "")
+
+ attrs := []attribute.KeyValue{
+ attribute.String("customer.document", "123456789"),
+ }
+
+ result1 := redactAttributesByKey(attrs, r)
+ result2 := redactAttributesByKey(attrs, r)
+
+ require.Len(t, result1, 1)
+ require.Len(t, result2, 1)
+ assert.Equal(t, result1[0].Value.AsString(), result2[0].Value.AsString())
+ assert.True(t, strings.HasPrefix(result1[0].Value.AsString(), "sha256:"))
+}
+
+func TestRedactAttributesByKey_KeyWithoutDot(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^password$`, Action: RedactionMask},
+ }, "***")
+
+ attrs := []attribute.KeyValue{
+ attribute.String("password", "secret"),
+ }
+
+ result := redactAttributesByKey(attrs, r)
+ require.Len(t, result, 1)
+ assert.Equal(t, "***", result[0].Value.AsString())
+}
+
+// ===========================================================================
+// 15. Processor interface compliance
+// ===========================================================================
+
+func TestAttrBagSpanProcessor_NoOpMethods(t *testing.T) {
+ t.Parallel()
+
+ p := AttrBagSpanProcessor{}
+ assert.NoError(t, p.Shutdown(nil))
+ assert.NoError(t, p.ForceFlush(nil))
+}
+
+func TestRedactingAttrBagSpanProcessor_NoOpMethods(t *testing.T) {
+ t.Parallel()
+
+ p := RedactingAttrBagSpanProcessor{}
+ assert.NoError(t, p.Shutdown(nil))
+ assert.NoError(t, p.ForceFlush(nil))
+}
+
+// ===========================================================================
+// 16. RedactionAction constants
+// ===========================================================================
+
+func TestRedactionActionConstants(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, RedactionAction("mask"), RedactionMask)
+ assert.Equal(t, RedactionAction("hash"), RedactionHash)
+ assert.Equal(t, RedactionAction("drop"), RedactionDrop)
+}
+
+// ===========================================================================
+// 17. Concurrency safety
+// ===========================================================================
+
+func TestObfuscateStruct_ConcurrentSafety(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^password$`, Action: RedactionMask},
+ {FieldPattern: `(?i)^email$`, Action: RedactionHash},
+ {FieldPattern: `(?i)^token$`, Action: RedactionDrop},
+ }, "***")
+
+ done := make(chan struct{}, 50)
+ for i := range 50 {
+ go func(idx int) {
+ defer func() { done <- struct{}{} }()
+
+ payload := map[string]any{
+ "id": fmt.Sprintf("user_%d", idx),
+ "password": "secret",
+ "email": fmt.Sprintf("user%d@test.com", idx),
+ "token": "tok_concurrent",
+ "data": map[string]any{
+ "password": "nested_secret",
+ },
+ }
+
+ result, err := ObfuscateStruct(payload, r)
+ if err != nil {
+ t.Errorf("concurrent ObfuscateStruct failed: %v", err)
+ return
+ }
+
+ m := result.(map[string]any)
+ if m["password"] != "***" {
+ t.Errorf("expected masked password, got %v", m["password"])
+ }
+ }(i)
+ }
+
+ for range 50 {
+ <-done
+ }
+}
+
+func TestRedactAttributesByKey_ConcurrentSafety(t *testing.T) {
+ t.Parallel()
+
+ r := mustRedactor(t, []RedactionRule{
+ {FieldPattern: `(?i)^password$`, Action: RedactionMask},
+ }, "***")
+
+ attrs := []attribute.KeyValue{
+ attribute.String("user.password", "secret"),
+ attribute.String("user.name", "alice"),
+ }
+
+ done := make(chan struct{}, 50)
+ for range 50 {
+ go func() {
+ defer func() { done <- struct{}{} }()
+ result := redactAttributesByKey(attrs, r)
+ if len(result) != 2 {
+ t.Errorf("expected 2 attributes, got %d", len(result))
+ }
+ }()
+ }
+
+ for range 50 {
+ <-done
+ }
+}
diff --git a/tracing/otel.go b/tracing/otel.go
new file mode 100644
index 0000000..2c443e9
--- /dev/null
+++ b/tracing/otel.go
@@ -0,0 +1,934 @@
+package tracing
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "maps"
+ "net/http"
+ "os"
+ "reflect"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ observability "github.com/LerianStudio/lib-observability"
+ "github.com/LerianStudio/lib-observability/assert"
+ constant "github.com/LerianStudio/lib-observability/constants"
+ "github.com/LerianStudio/lib-observability/log"
+ "github.com/LerianStudio/lib-observability/metrics"
+ "github.com/LerianStudio/lib-observability/redaction"
+ "github.com/gofiber/fiber/v2"
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/codes"
+ "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
+ "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
+ "go.opentelemetry.io/otel/exporters/otlp/otlptrace"
+ "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
+ "go.opentelemetry.io/otel/log/global"
+ "go.opentelemetry.io/otel/metric"
+ "go.opentelemetry.io/otel/propagation"
+ sdklog "go.opentelemetry.io/otel/sdk/log"
+ sdkmetric "go.opentelemetry.io/otel/sdk/metric"
+ sdkresource "go.opentelemetry.io/otel/sdk/resource"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ semconv "go.opentelemetry.io/otel/semconv/v1.34.0"
+ "go.opentelemetry.io/otel/trace"
+ "google.golang.org/grpc/metadata"
+)
+
+const (
+ maxSpanAttributeStringLength = 4096
+ maxAttributeDepth = 32
+ maxAttributeCount = 128
+ defaultAttrPrefix = "value"
+)
+
+var (
+ // ErrNilTelemetryLogger is returned when telemetry config has no logger.
+ ErrNilTelemetryLogger = errors.New("telemetry config logger cannot be nil")
+ // ErrEmptyEndpoint is returned when telemetry is enabled without exporter endpoint.
+ ErrEmptyEndpoint = errors.New("collector exporter endpoint cannot be empty when telemetry is enabled")
+ // ErrNilTelemetry is returned when a telemetry method receives a nil receiver.
+ ErrNilTelemetry = errors.New("telemetry instance is nil")
+ // ErrNilShutdown is returned when telemetry shutdown handlers are unavailable.
+ ErrNilShutdown = errors.New("telemetry shutdown function is nil")
+ // ErrNilProvider is returned when ApplyGlobals is called with nil providers.
+ ErrNilProvider = errors.New("telemetry providers must not be nil when applying globals")
+)
+
+// TelemetryConfig configures tracing, metrics, logging, and propagation behavior.
+type TelemetryConfig struct {
+ LibraryName string
+ ServiceName string
+ ServiceVersion string
+ DeploymentEnv string
+ CollectorExporterEndpoint string
+ EnableTelemetry bool
+ InsecureExporter bool
+ Logger log.Logger
+ Propagator propagation.TextMapPropagator
+ Redactor *Redactor
+}
+
+// Telemetry holds configured OpenTelemetry providers and lifecycle handlers.
+type Telemetry struct {
+ TelemetryConfig
+ TracerProvider *sdktrace.TracerProvider
+ MeterProvider *sdkmetric.MeterProvider
+ LoggerProvider *sdklog.LoggerProvider
+ MetricsFactory *metrics.MetricsFactory
+ shutdown func()
+ shutdownCtx func(context.Context) error
+}
+
+// NewTelemetry builds telemetry providers and exporters from configuration.
+func NewTelemetry(cfg TelemetryConfig) (*Telemetry, error) {
+ if cfg.Logger == nil {
+ return nil, ErrNilTelemetryLogger
+ }
+
+ if cfg.Propagator == nil {
+ cfg.Propagator = propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})
+ }
+
+ if cfg.Redactor == nil {
+ cfg.Redactor = NewDefaultRedactor()
+ }
+
+ normalizeEndpoint(&cfg)
+
+ if cfg.EnableTelemetry && strings.TrimSpace(cfg.CollectorExporterEndpoint) == "" {
+ return handleEmptyEndpoint(cfg)
+ }
+
+ ctx := context.Background()
+
+ if !cfg.EnableTelemetry {
+ cfg.Logger.Log(ctx, log.LevelWarn, "Telemetry disabled")
+
+ return newNoopTelemetry(cfg)
+ }
+
+ normalizedDeploymentEnv := strings.ToLower(strings.TrimSpace(cfg.DeploymentEnv))
+ if cfg.InsecureExporter && normalizedDeploymentEnv != "" &&
+ normalizedDeploymentEnv != "development" && normalizedDeploymentEnv != "local" {
+ cfg.Logger.Log(ctx, log.LevelWarn,
+ "InsecureExporter is enabled in non-development environment",
+ log.String("environment", normalizedDeploymentEnv))
+ }
+
+ // Security policy: insecure OTEL exporter enforcement in production.
+ // In production environments, InsecureExporter must not be used unless
+ // the ALLOW_INSECURE_OTEL env var is set with a justification.
+ if cfg.InsecureExporter && isStrictEnvironment(normalizedDeploymentEnv) {
+ if os.Getenv("ALLOW_INSECURE_OTEL") == "" {
+ return nil, fmt.Errorf("otel new: insecure exporter detected in %q environment (use https:// endpoint or set ALLOW_INSECURE_OTEL=\"reason\")",
+ normalizedDeploymentEnv)
+ }
+ }
+
+ return initExporters(ctx, cfg)
+}
+
+// normalizeEndpoint strips URL scheme from the collector endpoint and infers security mode.
+// gRPC WithEndpoint() expects host:port, not a full URL.
+// Consumers commonly pass OTEL_EXPORTER_OTLP_ENDPOINT as "http://host:4317".
+func normalizeEndpoint(cfg *TelemetryConfig) {
+ ep := strings.TrimSpace(cfg.CollectorExporterEndpoint)
+ if ep == "" {
+ return
+ }
+
+ switch {
+ case strings.HasPrefix(ep, "http://"):
+ cfg.CollectorExporterEndpoint = strings.TrimPrefix(ep, "http://")
+ cfg.InsecureExporter = true
+ case strings.HasPrefix(ep, "https://"):
+ cfg.CollectorExporterEndpoint = strings.TrimPrefix(ep, "https://")
+ default:
+ cfg.CollectorExporterEndpoint = ep
+ }
+}
+
+// handleEmptyEndpoint handles the case where telemetry is enabled but the collector
+// endpoint is empty, returning noop providers installed as globals.
+func handleEmptyEndpoint(cfg TelemetryConfig) (*Telemetry, error) {
+ cfg.Logger.Log(context.Background(), log.LevelWarn,
+ "Telemetry enabled but collector endpoint is empty; falling back to noop providers")
+
+ tl, noopErr := newNoopTelemetry(cfg)
+ if noopErr != nil {
+ return nil, noopErr
+ }
+
+ // Set noop providers as globals so downstream libraries (e.g. otelfiber)
+ // do not create real gRPC exporters that leak background goroutines.
+ _ = tl.ApplyGlobals()
+
+ return tl, ErrEmptyEndpoint
+}
+
+// initExporters creates OTLP exporters, providers, and a metrics factory,
+// rolling back partial allocations on failure.
+func initExporters(ctx context.Context, cfg TelemetryConfig) (*Telemetry, error) {
+ r := cfg.newResource()
+
+ // Track all allocated resources for rollback if a later step fails.
+ var cleanups []shutdownable
+
+ tExp, err := cfg.newTracerExporter(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("can't initialize tracer exporter: %w", err)
+ }
+
+ cleanups = append(cleanups, tExp)
+
+ mExp, err := cfg.newMetricExporter(ctx)
+ if err != nil {
+ shutdownAll(ctx, cleanups)
+
+ return nil, fmt.Errorf("can't initialize metric exporter: %w", err)
+ }
+
+ cleanups = append(cleanups, mExp)
+
+ lExp, err := cfg.newLoggerExporter(ctx)
+ if err != nil {
+ shutdownAll(ctx, cleanups)
+
+ return nil, fmt.Errorf("can't initialize logger exporter: %w", err)
+ }
+
+ cleanups = append(cleanups, lExp)
+
+ mp := cfg.newMeterProvider(r, mExp)
+ cleanups = append(cleanups, mp)
+
+ tp := cfg.newTracerProvider(r, tExp)
+ cleanups = append(cleanups, tp)
+
+ lp := cfg.newLoggerProvider(r, lExp)
+ cleanups = append(cleanups, lp)
+
+ metricsFactory, err := metrics.NewMetricsFactory(mp.Meter(cfg.LibraryName), cfg.Logger)
+ if err != nil {
+ shutdownAll(ctx, cleanups)
+
+ return nil, err
+ }
+
+ shutdown, shutdownCtx := buildShutdownHandlers(cfg.Logger, mp, tp, lp, tExp, mExp, lExp)
+
+ return &Telemetry{
+ TelemetryConfig: cfg,
+ TracerProvider: tp,
+ MeterProvider: mp,
+ LoggerProvider: lp,
+ MetricsFactory: metricsFactory,
+ shutdown: shutdown,
+ shutdownCtx: shutdownCtx,
+ }, nil
+}
+
+// newNoopTelemetry creates a Telemetry instance with no-op providers (no exporters).
+// This is used when telemetry is disabled or when the collector endpoint is empty,
+// ensuring global OTEL providers are safe no-ops that do not leak goroutines.
+func newNoopTelemetry(cfg TelemetryConfig) (*Telemetry, error) {
+ mp := sdkmetric.NewMeterProvider()
+ tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(RedactingAttrBagSpanProcessor{Redactor: cfg.Redactor}))
+ lp := sdklog.NewLoggerProvider()
+
+ metricsFactory, err := metrics.NewMetricsFactory(mp.Meter(cfg.LibraryName), cfg.Logger)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Telemetry{
+ TelemetryConfig: cfg,
+ TracerProvider: tp,
+ MeterProvider: mp,
+ LoggerProvider: lp,
+ MetricsFactory: metricsFactory,
+ shutdown: func() {},
+ shutdownCtx: func(context.Context) error { return nil },
+ }, nil
+}
+
+// shutdownAll performs best-effort shutdown of all allocated components.
+// Used during NewTelemetry to roll back partial allocations on failure.
+func shutdownAll(ctx context.Context, components []shutdownable) {
+ for _, c := range components {
+ if isNilShutdownable(c) {
+ continue
+ }
+
+ _ = c.Shutdown(ctx)
+ }
+}
+
+// ApplyGlobals sets this instance as the process-global OTEL providers/propagator.
+// Returns an error if any required provider is nil.
+func (tl *Telemetry) ApplyGlobals() error {
+ if tl == nil {
+ return ErrNilTelemetry
+ }
+
+ if tl.TracerProvider == nil || tl.MeterProvider == nil || tl.Propagator == nil {
+ return ErrNilProvider
+ }
+
+ otel.SetTracerProvider(tl.TracerProvider)
+ otel.SetMeterProvider(tl.MeterProvider)
+
+ if tl.LoggerProvider != nil {
+ global.SetLoggerProvider(tl.LoggerProvider)
+ }
+
+ otel.SetTextMapPropagator(tl.Propagator)
+
+ return nil
+}
+
+// Tracer returns a tracer from this telemetry instance.
+func (tl *Telemetry) Tracer(name string) (trace.Tracer, error) {
+ if tl == nil || tl.TracerProvider == nil {
+ // Logger is intentionally nil: nil/incomplete Telemetry means no reliable logger available.
+ asserter := assert.New(context.Background(), nil, "tracing", "Tracer")
+ _ = asserter.NoError(context.Background(), ErrNilTelemetry, "telemetry tracer provider is nil")
+
+ return nil, ErrNilTelemetry
+ }
+
+ return tl.TracerProvider.Tracer(name), nil
+}
+
+// Meter returns a meter from this telemetry instance.
+func (tl *Telemetry) Meter(name string) (metric.Meter, error) {
+ if tl == nil || tl.MeterProvider == nil {
+ // Logger is intentionally nil: nil/incomplete Telemetry means no reliable logger available.
+ asserter := assert.New(context.Background(), nil, "tracing", "Meter")
+ _ = asserter.NoError(context.Background(), ErrNilTelemetry, "telemetry meter provider is nil")
+
+ return nil, ErrNilTelemetry
+ }
+
+ return tl.MeterProvider.Meter(name), nil
+}
+
+// ShutdownTelemetry shuts down telemetry components using background context.
+func (tl *Telemetry) ShutdownTelemetry() {
+ if tl == nil {
+ return
+ }
+
+ if err := tl.ShutdownTelemetryWithContext(context.Background()); err != nil {
+ asserter := assert.New(context.Background(), tl.Logger, "tracing", "ShutdownTelemetry")
+ _ = asserter.NoError(context.Background(), err, "telemetry shutdown failed")
+
+ return
+ }
+}
+
+// ShutdownTelemetryWithContext shuts down telemetry components with caller context.
+func (tl *Telemetry) ShutdownTelemetryWithContext(ctx context.Context) error {
+ if tl == nil {
+ // Logger is intentionally nil: nil receiver means no Telemetry instance to extract logger from.
+ asserter := assert.New(context.Background(), nil, "tracing", "ShutdownTelemetryWithContext")
+ _ = asserter.NoError(context.Background(), ErrNilTelemetry, "cannot shutdown nil telemetry")
+
+ return ErrNilTelemetry
+ }
+
+ if tl.shutdownCtx != nil {
+ return tl.shutdownCtx(ctx)
+ }
+
+ if tl.shutdown != nil {
+ tl.shutdown()
+ return nil
+ }
+
+ asserter := assert.New(context.Background(), tl.Logger, "tracing", "ShutdownTelemetryWithContext")
+ _ = asserter.NoError(context.Background(), ErrNilShutdown, "cannot shutdown telemetry without configured shutdown function")
+
+ return ErrNilShutdown
+}
+
+func (tl *TelemetryConfig) newResource() *sdkresource.Resource {
+ return sdkresource.NewWithAttributes(
+ semconv.SchemaURL,
+ semconv.ServiceName(tl.ServiceName),
+ semconv.ServiceVersion(tl.ServiceVersion),
+ semconv.DeploymentEnvironmentName(tl.DeploymentEnv),
+ semconv.TelemetrySDKName(constant.TelemetrySDKName),
+ semconv.TelemetrySDKLanguageGo,
+ )
+}
+
+func (tl *TelemetryConfig) newLoggerExporter(ctx context.Context) (*otlploggrpc.Exporter, error) {
+ opts := []otlploggrpc.Option{otlploggrpc.WithEndpoint(tl.CollectorExporterEndpoint)}
+ if tl.InsecureExporter {
+ opts = append(opts, otlploggrpc.WithInsecure())
+ }
+
+ return otlploggrpc.New(ctx, opts...)
+}
+
+func (tl *TelemetryConfig) newMetricExporter(ctx context.Context) (*otlpmetricgrpc.Exporter, error) {
+ opts := []otlpmetricgrpc.Option{otlpmetricgrpc.WithEndpoint(tl.CollectorExporterEndpoint)}
+ if tl.InsecureExporter {
+ opts = append(opts, otlpmetricgrpc.WithInsecure())
+ }
+
+ return otlpmetricgrpc.New(ctx, opts...)
+}
+
+func (tl *TelemetryConfig) newTracerExporter(ctx context.Context) (*otlptrace.Exporter, error) {
+ opts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(tl.CollectorExporterEndpoint)}
+ if tl.InsecureExporter {
+ opts = append(opts, otlptracegrpc.WithInsecure())
+ }
+
+ return otlptracegrpc.New(ctx, opts...)
+}
+
+func (tl *TelemetryConfig) newLoggerProvider(rsc *sdkresource.Resource, exp *otlploggrpc.Exporter) *sdklog.LoggerProvider {
+ bp := sdklog.NewBatchProcessor(exp)
+ return sdklog.NewLoggerProvider(sdklog.WithResource(rsc), sdklog.WithProcessor(bp))
+}
+
+func (tl *TelemetryConfig) newMeterProvider(res *sdkresource.Resource, exp *otlpmetricgrpc.Exporter) *sdkmetric.MeterProvider {
+ return sdkmetric.NewMeterProvider(
+ sdkmetric.WithResource(res),
+ sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exp)),
+ )
+}
+
+func (tl *TelemetryConfig) newTracerProvider(rsc *sdkresource.Resource, exp *otlptrace.Exporter) *sdktrace.TracerProvider {
+ return sdktrace.NewTracerProvider(
+ sdktrace.WithResource(rsc),
+ sdktrace.WithSpanProcessor(RedactingAttrBagSpanProcessor{Redactor: tl.Redactor}),
+ sdktrace.WithBatcher(exp),
+ )
+}
+
+type shutdownable interface {
+ Shutdown(ctx context.Context) error
+}
+
+// isNilShutdownable checks for both untyped nil and interface-wrapped typed nil
+// (e.g., a concrete pointer that is nil but stored in a shutdownable interface).
+func isNilShutdownable(s shutdownable) bool {
+ if s == nil {
+ return true
+ }
+
+ v := reflect.ValueOf(s)
+
+ return v.Kind() == reflect.Pointer && v.IsNil()
+}
+
+func buildShutdownHandlers(l log.Logger, components ...shutdownable) (func(), func(context.Context) error) {
+ shutdown := func() {
+ ctx := context.Background()
+
+ for _, c := range components {
+ if isNilShutdownable(c) {
+ continue
+ }
+
+ if err := c.Shutdown(ctx); err != nil {
+ l.Log(ctx, log.LevelError, "telemetry shutdown error", log.Err(err))
+ }
+ }
+ }
+
+ shutdownCtx := func(ctx context.Context) error {
+ var errs []error
+
+ for _, c := range components {
+ if isNilShutdownable(c) {
+ continue
+ }
+
+ if err := c.Shutdown(ctx); err != nil {
+ errs = append(errs, err)
+ }
+ }
+
+ return errors.Join(errs...)
+ }
+
+ return shutdown, shutdownCtx
+}
+
+// isNilSpan checks for both untyped nil and interface-wrapped typed nil values.
+// trace.Span is an interface, so a concrete pointer that is nil but stored in
+// a trace.Span variable would pass a simple `span == nil` check.
+func isNilSpan(span trace.Span) bool {
+ if span == nil {
+ return true
+ }
+
+ v := reflect.ValueOf(span)
+
+ return v.Kind() == reflect.Pointer && v.IsNil()
+}
+
+// maxSpanErrorLength is the maximum length for error messages written to span status/events.
+const maxSpanErrorLength = 1024
+
+// sanitizeSpanMessage sanitizes an error message for span output:
+// - Truncates to a safe maximum length
+// - Strips common sensitive-looking patterns (bearer tokens, passwords in URLs)
+func sanitizeSpanMessage(msg string) string {
+ // Strip common sensitive patterns
+ for _, pattern := range []struct{ prefix, replacement string }{
+ {"Bearer ", "Bearer [REDACTED]"},
+ {"Basic ", "Basic [REDACTED]"},
+ } {
+ if idx := strings.Index(msg, pattern.prefix); idx >= 0 {
+ end := idx + len(pattern.prefix)
+ // Find the end of the token (next space or end of string)
+ tokenEnd := strings.IndexByte(msg[end:], ' ')
+ if tokenEnd < 0 {
+ msg = msg[:idx] + pattern.replacement
+ } else {
+ msg = msg[:idx] + pattern.replacement + msg[end+tokenEnd:]
+ }
+ }
+ }
+
+ if len(msg) > maxSpanErrorLength {
+ msg = msg[:maxSpanErrorLength]
+ // Ensure valid UTF-8 after truncation
+ if !utf8.ValidString(msg) {
+ msg = strings.ToValidUTF8(msg, "")
+ }
+ }
+
+ return msg
+}
+
+// HandleSpanBusinessErrorEvent records a business-error event on a span.
+func HandleSpanBusinessErrorEvent(span trace.Span, eventName string, err error) {
+ if isNilSpan(span) || err == nil {
+ return
+ }
+
+ span.AddEvent(eventName, trace.WithAttributes(attribute.String("error", sanitizeSpanMessage(err.Error()))))
+}
+
+// HandleSpanEvent records a generic event with optional attributes on a span.
+func HandleSpanEvent(span trace.Span, eventName string, attributes ...attribute.KeyValue) {
+ if isNilSpan(span) {
+ return
+ }
+
+ span.AddEvent(eventName, trace.WithAttributes(attributes...))
+}
+
+// HandleSpanError marks a span as failed and records the error.
+func HandleSpanError(span trace.Span, message string, err error) {
+ if isNilSpan(span) || err == nil {
+ return
+ }
+
+ // Build status message: avoid malformed ": " when message is empty
+ statusMsg := sanitizeSpanMessage(err.Error())
+ if message != "" {
+ statusMsg = sanitizeSpanMessage(message + ": " + statusMsg)
+ }
+
+ span.SetStatus(codes.Error, statusMsg)
+ span.RecordError(errors.New(statusMsg))
+}
+
+// SetSpanAttributesFromValue flattens a value and sets resulting attributes on a span.
+func SetSpanAttributesFromValue(span trace.Span, prefix string, value any, r *Redactor) error {
+ if isNilSpan(span) {
+ return nil
+ }
+
+ attrs, err := BuildAttributesFromValue(prefix, value, r)
+ if err != nil {
+ return err
+ }
+
+ if len(attrs) > 0 {
+ span.SetAttributes(attrs...)
+ }
+
+ return nil
+}
+
+// BuildAttributesFromValue flattens a value into OTEL attributes with optional redaction.
+func BuildAttributesFromValue(prefix string, value any, r *Redactor) ([]attribute.KeyValue, error) {
+ if value == nil {
+ return nil, nil
+ }
+
+ processed := value
+
+ if r != nil {
+ var err error
+
+ processed, err = ObfuscateStruct(value, r)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ b, err := json.Marshal(processed)
+ if err != nil {
+ return nil, err
+ }
+
+ // Use json.NewDecoder with UseNumber() to preserve numeric precision.
+ // This avoids float64 rounding for large integers (e.g., financial amounts).
+ var decoded any
+
+ dec := json.NewDecoder(bytes.NewReader(b))
+ dec.UseNumber()
+
+ if err := dec.Decode(&decoded); err != nil {
+ return nil, err
+ }
+
+ // Use fallback prefix for top-level scalars/slices to avoid empty keys.
+ effectivePrefix := sanitizeUTF8String(prefix)
+ if effectivePrefix == "" {
+ switch decoded.(type) {
+ case map[string]any:
+ // Maps expand their own keys; empty prefix is fine.
+ case []any:
+ effectivePrefix = "item"
+ default:
+ effectivePrefix = defaultAttrPrefix
+ }
+ }
+
+ attrs := make([]attribute.KeyValue, 0, 16)
+ flattenAttributes(&attrs, effectivePrefix, decoded, 0)
+
+ return attrs, nil
+}
+
+func flattenAttributes(attrs *[]attribute.KeyValue, prefix string, value any, depth int) {
+ if depth >= maxAttributeDepth {
+ return
+ }
+
+ if len(*attrs) >= maxAttributeCount {
+ return
+ }
+
+ switch v := value.(type) {
+ case map[string]any:
+ flattenMap(attrs, prefix, v, depth)
+ case []any:
+ flattenSlice(attrs, prefix, v, depth)
+ case string:
+ s := truncateUTF8(sanitizeUTF8String(v), maxSpanAttributeStringLength)
+ *attrs = append(*attrs, attribute.String(resolveKey(prefix, defaultAttrPrefix), s))
+ case float64:
+ *attrs = append(*attrs, attribute.Float64(resolveKey(prefix, defaultAttrPrefix), v))
+ case bool:
+ *attrs = append(*attrs, attribute.Bool(resolveKey(prefix, defaultAttrPrefix), v))
+ case json.Number:
+ flattenJSONNumber(attrs, prefix, v)
+ case nil:
+ return
+ default:
+ *attrs = append(*attrs, attribute.String(resolveKey(prefix, defaultAttrPrefix), sanitizeUTF8String(fmt.Sprint(v))))
+ }
+}
+
+// resolveKey returns prefix if non-empty, otherwise falls back to fallback.
+func resolveKey(prefix, fallback string) string {
+ if prefix == "" {
+ return fallback
+ }
+
+ return prefix
+}
+
+func flattenMap(attrs *[]attribute.KeyValue, prefix string, m map[string]any, depth int) {
+ for key, child := range m {
+ next := sanitizeUTF8String(key)
+ if prefix != "" {
+ next = prefix + "." + next
+ }
+
+ flattenAttributes(attrs, next, child, depth+1)
+ }
+}
+
+func flattenSlice(attrs *[]attribute.KeyValue, prefix string, s []any, depth int) {
+ idxKey := resolveKey(prefix, "item")
+ for i, child := range s {
+ next := idxKey + "." + strconv.Itoa(i)
+ flattenAttributes(attrs, next, child, depth+1)
+ }
+}
+
+func flattenJSONNumber(attrs *[]attribute.KeyValue, prefix string, v json.Number) {
+ key := resolveKey(prefix, defaultAttrPrefix)
+
+ // Try Int64 first for precision, fall back to Float64
+ if i, err := v.Int64(); err == nil {
+ *attrs = append(*attrs, attribute.Int64(key, i))
+ } else if f, err := v.Float64(); err == nil {
+ *attrs = append(*attrs, attribute.Float64(key, f))
+ } else {
+ *attrs = append(*attrs, attribute.String(key, string(v)))
+ }
+}
+
+// truncateUTF8 truncates a string to at most maxBytes, ensuring the result is valid UTF-8.
+// If the byte-slice cut lands in the middle of a multi-byte rune, incomplete trailing bytes
+// are trimmed so the result is always valid.
+func truncateUTF8(s string, maxBytes int) string {
+ if len(s) <= maxBytes {
+ return s
+ }
+
+ s = s[:maxBytes]
+
+ // If the truncation produced invalid UTF-8, trim the trailing incomplete rune
+ for len(s) > 0 && !utf8.ValidString(s) {
+ s = s[:len(s)-1]
+ }
+
+ return s
+}
+
+// SetSpanAttributeForParam adds a request parameter attribute to the current context bag.
+// Sensitive parameter names (as determined by redaction.IsSensitiveField) are masked.
+func SetSpanAttributeForParam(c *fiber.Ctx, param, value, entityName string) {
+ if c == nil {
+ return
+ }
+
+ spanAttrKey := "app.request." + param
+ if entityName != "" && param == "id" {
+ spanAttrKey = "app.request." + entityName + "_id"
+ }
+
+ // Mask value if the parameter name is considered sensitive
+ attrValue := value
+ if redaction.IsSensitiveField(param) {
+ attrValue = "[REDACTED]"
+ }
+
+ c.SetUserContext(observability.ContextWithSpanAttributes(c.UserContext(), attribute.String(spanAttrKey, attrValue)))
+}
+
+// InjectTraceContext injects trace context into a generic text map carrier.
+func InjectTraceContext(ctx context.Context, carrier propagation.TextMapCarrier) {
+ if carrier == nil {
+ return
+ }
+
+ otel.GetTextMapPropagator().Inject(ctx, carrier)
+}
+
+// ExtractTraceContext extracts trace context from a generic text map carrier.
+func ExtractTraceContext(ctx context.Context, carrier propagation.TextMapCarrier) context.Context {
+ if carrier == nil {
+ return ctx
+ }
+
+ return otel.GetTextMapPropagator().Extract(ctx, carrier)
+}
+
+// InjectHTTPContext injects trace headers into HTTP headers.
+func InjectHTTPContext(ctx context.Context, headers http.Header) {
+ if headers == nil {
+ return
+ }
+
+ InjectTraceContext(ctx, propagation.HeaderCarrier(headers))
+}
+
+// ExtractHTTPContext extracts trace headers from a Fiber request.
+func ExtractHTTPContext(ctx context.Context, c *fiber.Ctx) context.Context {
+ if c == nil {
+ return ctx
+ }
+
+ carrier := propagation.HeaderCarrier{}
+ for key, value := range c.Request().Header.All() {
+ carrier.Set(string(key), string(value))
+ }
+
+ return ExtractTraceContext(ctx, carrier)
+}
+
+// InjectGRPCContext injects trace context into gRPC metadata.
+func InjectGRPCContext(ctx context.Context, md metadata.MD) metadata.MD {
+ if md == nil {
+ md = metadata.New(nil)
+ }
+
+ InjectTraceContext(ctx, propagation.HeaderCarrier(md))
+
+ if traceparentValues, exists := md[constant.HeaderTraceparentPascal]; exists && len(traceparentValues) > 0 {
+ md[constant.MetadataTraceparent] = traceparentValues
+ delete(md, constant.HeaderTraceparentPascal)
+ }
+
+ if tracestateValues, exists := md[constant.HeaderTracestatePascal]; exists && len(tracestateValues) > 0 {
+ md[constant.MetadataTracestate] = tracestateValues
+ delete(md, constant.HeaderTracestatePascal)
+ }
+
+ return md
+}
+
+// ExtractGRPCContext extracts trace context from gRPC metadata.
+func ExtractGRPCContext(ctx context.Context, md metadata.MD) context.Context {
+ if md == nil {
+ return ctx
+ }
+
+ mdCopy := md.Copy()
+
+ if traceparentValues, exists := mdCopy[constant.MetadataTraceparent]; exists && len(traceparentValues) > 0 {
+ mdCopy[constant.HeaderTraceparentPascal] = traceparentValues
+ delete(mdCopy, constant.MetadataTraceparent)
+ }
+
+ if tracestateValues, exists := mdCopy[constant.MetadataTracestate]; exists && len(tracestateValues) > 0 {
+ mdCopy[constant.HeaderTracestatePascal] = tracestateValues
+ delete(mdCopy, constant.MetadataTracestate)
+ }
+
+ return ExtractTraceContext(ctx, propagation.HeaderCarrier(mdCopy))
+}
+
+// InjectQueueTraceContext serializes trace context to string headers for queues.
+func InjectQueueTraceContext(ctx context.Context) map[string]string {
+ carrier := propagation.HeaderCarrier{}
+ InjectTraceContext(ctx, carrier)
+
+ headers := make(map[string]string, len(carrier))
+ for k, v := range carrier {
+ if len(v) > 0 {
+ headers[k] = v[0]
+ }
+ }
+
+ return headers
+}
+
+// ExtractQueueTraceContext extracts trace context from queue string headers.
+func ExtractQueueTraceContext(ctx context.Context, headers map[string]string) context.Context {
+ if headers == nil {
+ return ctx
+ }
+
+ carrier := propagation.HeaderCarrier{}
+ for k, v := range headers {
+ carrier.Set(k, v)
+ }
+
+ return ExtractTraceContext(ctx, carrier)
+}
+
+// PrepareQueueHeaders merges base headers with propagated trace headers.
+func PrepareQueueHeaders(ctx context.Context, baseHeaders map[string]any) map[string]any {
+ headers := make(map[string]any)
+ maps.Copy(headers, baseHeaders)
+
+ traceHeaders := InjectQueueTraceContext(ctx)
+ for k, v := range traceHeaders {
+ headers[k] = v
+ }
+
+ return headers
+}
+
+// InjectTraceHeadersIntoQueue injects propagated trace headers into a mutable map.
+func InjectTraceHeadersIntoQueue(ctx context.Context, headers *map[string]any) {
+ if headers == nil {
+ return
+ }
+
+ if *headers == nil {
+ *headers = make(map[string]any)
+ }
+
+ traceHeaders := InjectQueueTraceContext(ctx)
+ for k, v := range traceHeaders {
+ (*headers)[k] = v
+ }
+}
+
+// ExtractTraceContextFromQueueHeaders extracts trace context from AMQP-style headers.
+func ExtractTraceContextFromQueueHeaders(baseCtx context.Context, amqpHeaders map[string]any) context.Context {
+ if len(amqpHeaders) == 0 {
+ return baseCtx
+ }
+
+ traceHeaders := make(map[string]string)
+
+ for k, v := range amqpHeaders {
+ if str, ok := v.(string); ok {
+ traceHeaders[k] = str
+ }
+ }
+
+ if len(traceHeaders) == 0 {
+ return baseCtx
+ }
+
+ return ExtractQueueTraceContext(baseCtx, traceHeaders)
+}
+
+// GetTraceIDFromContext returns the current span trace ID, or empty if unavailable.
+func GetTraceIDFromContext(ctx context.Context) string {
+ span := trace.SpanFromContext(ctx)
+
+ sc := span.SpanContext()
+ if !sc.IsValid() {
+ return ""
+ }
+
+ return sc.TraceID().String()
+}
+
+// GetTraceStateFromContext returns the current span tracestate, or empty if unavailable.
+func GetTraceStateFromContext(ctx context.Context) string {
+ span := trace.SpanFromContext(ctx)
+
+ sc := span.SpanContext()
+ if !sc.IsValid() {
+ return ""
+ }
+
+ return sc.TraceState().String()
+}
+
+func sanitizeUTF8String(s string) string {
+ if !utf8.ValidString(s) {
+ return strings.ToValidUTF8(s, "")
+ }
+
+ return s
+}
+
+// isStrictEnvironment returns true for production-like environments
+// where insecure OTEL exporters must not be used.
+func isStrictEnvironment(env string) bool {
+ switch env {
+ case "production", "prod":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/tracing/otel_test.go b/tracing/otel_test.go
new file mode 100644
index 0000000..f96114a
--- /dev/null
+++ b/tracing/otel_test.go
@@ -0,0 +1,1230 @@
+//go:build unit
+
+package tracing
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+
+ observability "github.com/LerianStudio/lib-observability"
+ "github.com/LerianStudio/lib-observability/log"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/codes"
+ "go.opentelemetry.io/otel/propagation"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ "go.opentelemetry.io/otel/sdk/trace/tracetest"
+ "go.opentelemetry.io/otel/trace"
+ "google.golang.org/grpc/metadata"
+)
+
+func unsetEnvVar(t *testing.T, key string) {
+ t.Helper()
+
+ original, present := os.LookupEnv(key)
+ require.NoError(t, os.Unsetenv(key))
+ t.Cleanup(func() {
+ if present {
+ require.NoError(t, os.Setenv(key, original))
+ return
+ }
+
+ require.NoError(t, os.Unsetenv(key))
+ })
+}
+
+// ===========================================================================
+// 1. NewTelemetry validation
+// ===========================================================================
+
+func TestNewTelemetry_NilLogger(t *testing.T) {
+ t.Parallel()
+
+ tl, err := NewTelemetry(TelemetryConfig{
+ EnableTelemetry: false,
+ })
+ require.ErrorIs(t, err, ErrNilTelemetryLogger)
+ assert.Nil(t, tl)
+}
+
+func TestNewTelemetry_EnabledEmptyEndpoint(t *testing.T) {
+ t.Parallel()
+
+ tl, err := NewTelemetry(TelemetryConfig{
+ EnableTelemetry: true,
+ LibraryName: "test-lib",
+ Logger: log.NewNop(),
+ })
+ require.ErrorIs(t, err, ErrEmptyEndpoint)
+ require.NotNil(t, tl, "must return noop Telemetry to prevent goroutine leaks")
+ assert.NotNil(t, tl.TracerProvider)
+ assert.NotNil(t, tl.MeterProvider)
+ assert.NotNil(t, tl.LoggerProvider)
+ assert.NotNil(t, tl.MetricsFactory)
+}
+
+func TestNewTelemetry_EnabledWhitespaceEndpoint(t *testing.T) {
+ t.Parallel()
+
+ tl, err := NewTelemetry(TelemetryConfig{
+ EnableTelemetry: true,
+ CollectorExporterEndpoint: " ",
+ LibraryName: "test-lib",
+ Logger: log.NewNop(),
+ })
+ require.ErrorIs(t, err, ErrEmptyEndpoint)
+ require.NotNil(t, tl, "must return noop Telemetry to prevent goroutine leaks")
+ assert.NotNil(t, tl.TracerProvider)
+ assert.NotNil(t, tl.MeterProvider)
+ assert.NotNil(t, tl.LoggerProvider)
+ assert.NotNil(t, tl.MetricsFactory)
+}
+
+func TestNewTelemetry_EnabledEmptyEndpoint_SetsGlobalNoopProviders(t *testing.T) {
+ // Not parallel: mutates global OTEL providers.
+ prevTP := otel.GetTracerProvider()
+ prevMP := otel.GetMeterProvider()
+ t.Cleanup(func() {
+ otel.SetTracerProvider(prevTP)
+ otel.SetMeterProvider(prevMP)
+ })
+
+ tl, err := NewTelemetry(TelemetryConfig{
+ EnableTelemetry: true,
+ LibraryName: "test-lib",
+ Logger: log.NewNop(),
+ })
+ require.ErrorIs(t, err, ErrEmptyEndpoint)
+ require.NotNil(t, tl)
+
+ assert.Same(t, tl.TracerProvider, otel.GetTracerProvider(),
+ "global tracer provider must be the noop instance")
+ assert.Same(t, tl.MeterProvider, otel.GetMeterProvider(),
+ "global meter provider must be the noop instance")
+}
+
+func TestNewTelemetry_DisabledReturnsNoopProviders(t *testing.T) {
+ t.Parallel()
+
+ tl, err := NewTelemetry(TelemetryConfig{
+ LibraryName: "test-lib",
+ ServiceName: "test-svc",
+ ServiceVersion: "0.1.0",
+ DeploymentEnv: "test",
+ EnableTelemetry: false,
+ Logger: log.NewNop(),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, tl)
+ assert.NotNil(t, tl.TracerProvider)
+ assert.NotNil(t, tl.MeterProvider)
+ assert.NotNil(t, tl.LoggerProvider)
+ assert.NotNil(t, tl.MetricsFactory)
+ assert.NotNil(t, tl.Redactor)
+ assert.NotNil(t, tl.Propagator)
+}
+
+func TestNewTelemetry_DefaultPropagatorAndRedactor(t *testing.T) {
+ t.Parallel()
+
+ tl, err := NewTelemetry(TelemetryConfig{
+ LibraryName: "test-lib",
+ EnableTelemetry: false,
+ Logger: log.NewNop(),
+ })
+ require.NoError(t, err)
+ assert.NotNil(t, tl.Propagator, "default propagator should be set")
+ assert.NotNil(t, tl.Redactor, "default redactor should be set")
+}
+
+func TestNewTelemetry_DeploymentEnvControlsSecurityPolicy(t *testing.T) {
+ t.Run("explicit production env blocks insecure exporter", func(t *testing.T) {
+ // Allow_insecure_otel is not set — production should block insecure exporter
+ unsetEnvVar(t, "ALLOW_INSECURE_OTEL")
+ t.Setenv("ENV_NAME", "")
+ t.Setenv("ENV", "")
+ t.Setenv("GO_ENV", "")
+
+ tl, err := NewTelemetry(TelemetryConfig{
+ LibraryName: "test-lib",
+ EnableTelemetry: true,
+ DeploymentEnv: "production",
+ CollectorExporterEndpoint: "http://collector:4317",
+ Logger: log.NewNop(),
+ })
+ require.Error(t, err)
+ assert.Nil(t, tl)
+ })
+
+ t.Run("explicit local env allows insecure exporter", func(t *testing.T) {
+ unsetEnvVar(t, "ALLOW_INSECURE_OTEL")
+ t.Setenv("ENV_NAME", "")
+ t.Setenv("ENV", "")
+ t.Setenv("GO_ENV", "")
+
+ tl, err := NewTelemetry(TelemetryConfig{
+ LibraryName: "test-lib",
+ EnableTelemetry: true,
+ DeploymentEnv: "local",
+ CollectorExporterEndpoint: "http://collector:4317",
+ Logger: log.NewNop(),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, tl)
+ require.NotNil(t, tl.shutdownCtx)
+ // Avoid network-coupled expectations in unit tests.
+ _ = tl.ShutdownTelemetryWithContext(context.Background())
+ })
+}
+
+// ===========================================================================
+// 1b. Endpoint normalization
+// ===========================================================================
+
+func TestNewTelemetry_EndpointNormalization(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ endpoint string
+ wantEndpoint string
+ wantInsecure bool
+ insecureOverride bool
+ }{
+ {
+ name: "http scheme stripped and insecure inferred",
+ endpoint: "http://otel-collector:4317",
+ wantEndpoint: "otel-collector:4317",
+ wantInsecure: true,
+ },
+ {
+ name: "https scheme stripped and insecure stays false",
+ endpoint: "https://otel-collector:4317",
+ wantEndpoint: "otel-collector:4317",
+ wantInsecure: false,
+ },
+ {
+ name: "no scheme preserves insecure default",
+ endpoint: "otel-collector:4317",
+ wantEndpoint: "otel-collector:4317",
+ wantInsecure: false,
+ },
+ {
+ name: "https with explicit insecure override preserved",
+ endpoint: "https://otel-collector:4317",
+ insecureOverride: true,
+ wantEndpoint: "otel-collector:4317",
+ wantInsecure: true,
+ },
+ {
+ name: "http with trailing slash",
+ endpoint: "http://otel-collector:4317/",
+ wantEndpoint: "otel-collector:4317/",
+ wantInsecure: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ tl, err := NewTelemetry(TelemetryConfig{
+ LibraryName: "test-lib",
+ EnableTelemetry: false,
+ CollectorExporterEndpoint: tt.endpoint,
+ InsecureExporter: tt.insecureOverride,
+ Logger: log.NewNop(),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, tl)
+ assert.Equal(t, tt.wantEndpoint, tl.CollectorExporterEndpoint,
+ "endpoint should be normalized")
+ assert.Equal(t, tt.wantInsecure, tl.InsecureExporter,
+ "InsecureExporter should be inferred from scheme")
+ })
+ }
+}
+
+// ===========================================================================
+// 2. Telemetry methods on nil receiver
+// ===========================================================================
+
+func TestTelemetry_ApplyGlobals_NilReceiver(t *testing.T) {
+ t.Parallel()
+
+ var tl *Telemetry
+ err := tl.ApplyGlobals()
+ require.ErrorIs(t, err, ErrNilTelemetry)
+}
+
+func TestTelemetry_Tracer_NilReceiver(t *testing.T) {
+ t.Parallel()
+
+ var tl *Telemetry
+ tr, err := tl.Tracer("test")
+ require.ErrorIs(t, err, ErrNilTelemetry)
+ assert.Nil(t, tr)
+}
+
+func TestTelemetry_Meter_NilReceiver(t *testing.T) {
+ t.Parallel()
+
+ var tl *Telemetry
+ m, err := tl.Meter("test")
+ require.ErrorIs(t, err, ErrNilTelemetry)
+ assert.Nil(t, m)
+}
+
+func TestTelemetry_ShutdownTelemetry_NilReceiver(t *testing.T) {
+ t.Parallel()
+
+ var tl *Telemetry
+ assert.NotPanics(t, func() { tl.ShutdownTelemetry() })
+}
+
+func TestTelemetry_ShutdownTelemetryWithContext_NilReceiver(t *testing.T) {
+ t.Parallel()
+
+ var tl *Telemetry
+ err := tl.ShutdownTelemetryWithContext(context.Background())
+ require.ErrorIs(t, err, ErrNilTelemetry)
+}
+
+// ===========================================================================
+// 3. Telemetry with disabled telemetry — provider access
+// ===========================================================================
+
+func newDisabledTelemetry(t *testing.T) *Telemetry {
+ t.Helper()
+
+ tl, err := NewTelemetry(TelemetryConfig{
+ LibraryName: "test-lib",
+ ServiceName: "test-svc",
+ ServiceVersion: "0.1.0",
+ EnableTelemetry: false,
+ Logger: log.NewNop(),
+ })
+ require.NoError(t, err)
+
+ return tl
+}
+
+func TestTelemetry_Disabled_Tracer(t *testing.T) {
+ t.Parallel()
+
+ tl := newDisabledTelemetry(t)
+ tr, err := tl.Tracer("test-tracer")
+ require.NoError(t, err)
+ assert.NotNil(t, tr)
+}
+
+func TestTelemetry_Disabled_Meter(t *testing.T) {
+ t.Parallel()
+
+ tl := newDisabledTelemetry(t)
+ m, err := tl.Meter("test-meter")
+ require.NoError(t, err)
+ assert.NotNil(t, m)
+}
+
+func TestTelemetry_Disabled_ShutdownWithContext(t *testing.T) {
+ t.Parallel()
+
+ tl := newDisabledTelemetry(t)
+ err := tl.ShutdownTelemetryWithContext(context.Background())
+ require.NoError(t, err)
+}
+
+func TestTelemetry_Disabled_ShutdownTelemetry(t *testing.T) {
+ t.Parallel()
+
+ tl := newDisabledTelemetry(t)
+ assert.NotPanics(t, func() { tl.ShutdownTelemetry() })
+}
+
+func TestTelemetry_Disabled_ApplyGlobals(t *testing.T) {
+ prevTP := otel.GetTracerProvider()
+ prevMP := otel.GetMeterProvider()
+ t.Cleanup(func() {
+ otel.SetTracerProvider(prevTP)
+ otel.SetMeterProvider(prevMP)
+ })
+
+ tl := newDisabledTelemetry(t)
+ require.NoError(t, tl.ApplyGlobals())
+ assert.Same(t, tl.TracerProvider, otel.GetTracerProvider())
+ assert.Same(t, tl.MeterProvider, otel.GetMeterProvider())
+}
+
+// ===========================================================================
+// 4. ShutdownTelemetryWithContext — nil shutdown functions
+// ===========================================================================
+
+func TestTelemetry_ShutdownWithContext_NilShutdownFuncs(t *testing.T) {
+ t.Parallel()
+
+ tl := &Telemetry{
+ TelemetryConfig: TelemetryConfig{Logger: log.NewNop()},
+ shutdown: nil,
+ shutdownCtx: nil,
+ }
+
+ err := tl.ShutdownTelemetryWithContext(context.Background())
+ require.ErrorIs(t, err, ErrNilShutdown)
+}
+
+func TestTelemetry_ShutdownWithContext_FallbackToShutdown(t *testing.T) {
+ t.Parallel()
+
+ called := false
+ tl := &Telemetry{
+ TelemetryConfig: TelemetryConfig{Logger: log.NewNop()},
+ shutdown: func() { called = true },
+ shutdownCtx: nil,
+ }
+
+ err := tl.ShutdownTelemetryWithContext(context.Background())
+ require.NoError(t, err)
+ assert.True(t, called, "fallback shutdown should have been invoked")
+}
+
+// ===========================================================================
+// 5. Context propagation helpers — nil/empty edge cases
+// ===========================================================================
+
+func TestInjectTraceContext_NilCarrier(t *testing.T) {
+ t.Parallel()
+ assert.NotPanics(t, func() { InjectTraceContext(context.Background(), nil) })
+}
+
+func TestExtractTraceContext_NilCarrier(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ result := ExtractTraceContext(ctx, nil)
+ assert.Equal(t, ctx, result)
+}
+
+func TestInjectHTTPContext_NilHeaders(t *testing.T) {
+ t.Parallel()
+ assert.NotPanics(t, func() { InjectHTTPContext(context.Background(), nil) })
+}
+
+func TestInjectGRPCContext_NilMD(t *testing.T) {
+ t.Parallel()
+
+ md := InjectGRPCContext(context.Background(), nil)
+ require.NotNil(t, md, "nil md should produce a new metadata.MD")
+}
+
+func TestExtractGRPCContext_NilMD(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ result := ExtractGRPCContext(ctx, nil)
+ assert.Equal(t, ctx, result)
+}
+
+func TestExtractGRPCContext_WithTraceparentKey(t *testing.T) {
+ t.Parallel()
+
+ md := metadata.MD{
+ "traceparent": {"00-00112233445566778899aabbccddeeff-0123456789abcdef-01"},
+ }
+ ctx := ExtractGRPCContext(context.Background(), md)
+ assert.NotNil(t, ctx)
+
+ span := trace.SpanFromContext(ctx)
+ assert.Equal(t, "00112233445566778899aabbccddeeff", span.SpanContext().TraceID().String())
+}
+
+func TestInjectQueueTraceContext_ReturnsMap(t *testing.T) {
+ t.Parallel()
+
+ headers := InjectQueueTraceContext(context.Background())
+ require.NotNil(t, headers)
+}
+
+func TestExtractQueueTraceContext_NilHeaders(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ result := ExtractQueueTraceContext(ctx, nil)
+ assert.Equal(t, ctx, result)
+}
+
+func TestPrepareQueueHeaders_MergesHeaders(t *testing.T) {
+ t.Parallel()
+
+ base := map[string]any{"routing_key": "my.queue"}
+ result := PrepareQueueHeaders(context.Background(), base)
+ require.NotNil(t, result)
+ assert.Equal(t, "my.queue", result["routing_key"])
+}
+
+func TestPrepareQueueHeaders_DoesNotMutateBase(t *testing.T) {
+ t.Parallel()
+
+ base := map[string]any{"key": "val"}
+ result := PrepareQueueHeaders(context.Background(), base)
+ assert.Len(t, base, 1)
+ assert.NotSame(t, &base, &result)
+}
+
+func TestInjectTraceHeadersIntoQueue_NilPointer(t *testing.T) {
+ t.Parallel()
+ assert.NotPanics(t, func() { InjectTraceHeadersIntoQueue(context.Background(), nil) })
+}
+
+func TestInjectTraceHeadersIntoQueue_NilMap(t *testing.T) {
+ t.Parallel()
+
+ var headers map[string]any
+ InjectTraceHeadersIntoQueue(context.Background(), &headers)
+ require.NotNil(t, headers, "nil *map should be initialized")
+}
+
+func TestInjectTraceHeadersIntoQueue_ValidMap(t *testing.T) {
+ t.Parallel()
+
+ headers := map[string]any{"existing": "value"}
+ InjectTraceHeadersIntoQueue(context.Background(), &headers)
+ assert.Equal(t, "value", headers["existing"])
+}
+
+func TestExtractTraceContextFromQueueHeaders_EmptyHeaders(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ result := ExtractTraceContextFromQueueHeaders(ctx, nil)
+ assert.Equal(t, ctx, result)
+
+ result = ExtractTraceContextFromQueueHeaders(ctx, map[string]any{})
+ assert.Equal(t, ctx, result)
+}
+
+func TestExtractTraceContextFromQueueHeaders_NonStringValues(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ headers := map[string]any{
+ "traceparent": 12345,
+ "other": true,
+ }
+ result := ExtractTraceContextFromQueueHeaders(ctx, headers)
+ assert.Equal(t, ctx, result, "non-string values should be skipped, returning original ctx")
+}
+
+func TestExtractTraceContextFromQueueHeaders_ValidHeaders(t *testing.T) {
+ prev := otel.GetTextMapPropagator()
+ t.Cleanup(func() { otel.SetTextMapPropagator(prev) })
+ otel.SetTextMapPropagator(propagation.TraceContext{})
+
+ headers := map[string]any{
+ "traceparent": "00-00112233445566778899aabbccddeeff-0123456789abcdef-01",
+ }
+ ctx := ExtractTraceContextFromQueueHeaders(context.Background(), headers)
+ span := trace.SpanFromContext(ctx)
+ assert.Equal(t, "00112233445566778899aabbccddeeff", span.SpanContext().TraceID().String())
+}
+
+// ===========================================================================
+// 6. GetTraceIDFromContext / GetTraceStateFromContext
+// ===========================================================================
+
+func TestGetTraceIDFromContext_NoActiveSpan(t *testing.T) {
+ t.Parallel()
+ assert.Empty(t, GetTraceIDFromContext(context.Background()))
+}
+
+func TestGetTraceStateFromContext_NoActiveSpan(t *testing.T) {
+ t.Parallel()
+ assert.Empty(t, GetTraceStateFromContext(context.Background()))
+}
+
+func TestGetTraceIDFromContext_WithSpan(t *testing.T) {
+ t.Parallel()
+
+ tp := sdktrace.NewTracerProvider()
+ t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
+
+ ctx, span := tp.Tracer("test").Start(context.Background(), "op")
+ defer span.End()
+
+ traceID := GetTraceIDFromContext(ctx)
+ assert.NotEmpty(t, traceID)
+ assert.Len(t, traceID, 32) // hex-encoded 16-byte trace ID
+}
+
+func TestGetTraceStateFromContext_WithSpan(t *testing.T) {
+ t.Parallel()
+
+ tp := sdktrace.NewTracerProvider()
+ t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
+
+ ctx, span := tp.Tracer("test").Start(context.Background(), "op")
+ defer span.End()
+
+ state := GetTraceStateFromContext(ctx)
+ assert.Empty(t, state, "fresh local span context should have empty tracestate")
+}
+
+// ===========================================================================
+// 7. flattenAttributes via BodyToSpanAttributes / BuildAttributesFromValue
+// ===========================================================================
+
+func TestFlattenAttributes_NestedMap(t *testing.T) {
+ t.Parallel()
+
+ attrs, err := BuildAttributesFromValue("root", map[string]any{
+ "user": map[string]any{
+ "name": "alice",
+ "age": float64(30),
+ },
+ "active": true,
+ }, nil)
+ require.NoError(t, err)
+
+ m := attrsToMap(attrs)
+ assert.Equal(t, "alice", m["root.user.name"])
+ assert.Contains(t, m, "root.user.age")
+ assert.Contains(t, m, "root.active")
+}
+
+func TestFlattenAttributes_Array(t *testing.T) {
+ t.Parallel()
+
+ attrs, err := BuildAttributesFromValue("items", map[string]any{
+ "list": []any{"a", "b"},
+ }, nil)
+ require.NoError(t, err)
+
+ m := attrsToMap(attrs)
+ assert.Equal(t, "a", m["items.list.0"])
+ assert.Equal(t, "b", m["items.list.1"])
+}
+
+func TestFlattenAttributes_NilValue(t *testing.T) {
+ t.Parallel()
+
+ attrs, err := BuildAttributesFromValue("prefix", nil, nil)
+ require.NoError(t, err)
+ assert.Nil(t, attrs)
+}
+
+func TestFlattenAttributes_StringTruncation(t *testing.T) {
+ t.Parallel()
+
+ longStr := strings.Repeat("x", maxSpanAttributeStringLength+500)
+ attrs, err := BuildAttributesFromValue("k", map[string]any{"v": longStr}, nil)
+ require.NoError(t, err)
+ require.Len(t, attrs, 1)
+ assert.Len(t, attrs[0].Value.AsString(), maxSpanAttributeStringLength)
+}
+
+func TestFlattenAttributes_DepthLimit(t *testing.T) {
+ t.Parallel()
+
+ nested := map[string]any{"leaf": "value"}
+ for i := 0; i < maxAttributeDepth+5; i++ {
+ nested = map[string]any{"level": nested}
+ }
+
+ var attrs []attribute.KeyValue
+ flattenAttributes(&attrs, "root", nested, 0)
+
+ for _, a := range attrs {
+ assert.NotContains(t, string(a.Key), "leaf")
+ }
+}
+
+func TestFlattenAttributes_CountLimit(t *testing.T) {
+ t.Parallel()
+
+ wide := make(map[string]any, maxAttributeCount+50)
+ for i := 0; i < maxAttributeCount+50; i++ {
+ wide[strings.Repeat("k", 3)+strings.Repeat("0", 4)+string(rune('a'+i%26))+strings.Repeat("0", 3)] = "v"
+ }
+
+ var attrs []attribute.KeyValue
+ flattenAttributes(&attrs, "root", wide, 0)
+
+ assert.LessOrEqual(t, len(attrs), maxAttributeCount)
+}
+
+func TestFlattenAttributes_JsonNumber(t *testing.T) {
+ t.Parallel()
+
+ attrs, err := BuildAttributesFromValue("n", map[string]any{
+ "count": float64(42),
+ }, nil)
+ require.NoError(t, err)
+
+ m := attrsToMap(attrs)
+ assert.Contains(t, m, "n.count")
+}
+
+func TestFlattenAttributes_BoolValues(t *testing.T) {
+ t.Parallel()
+
+ attrs, err := BuildAttributesFromValue("cfg", map[string]any{
+ "enabled": true,
+ "debug": false,
+ }, nil)
+ require.NoError(t, err)
+ assert.Len(t, attrs, 2)
+}
+
+// ===========================================================================
+// 8. sanitizeUTF8String
+// ===========================================================================
+
+func TestSanitizeUTF8String_ValidString(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, "hello world", sanitizeUTF8String("hello world"))
+}
+
+func TestSanitizeUTF8String_InvalidUTF8(t *testing.T) {
+ t.Parallel()
+
+ invalid := "hello\x80world"
+ result := sanitizeUTF8String(invalid)
+ assert.NotContains(t, result, "\x80")
+ assert.Contains(t, result, "hello")
+ assert.Contains(t, result, "world")
+}
+
+func TestSanitizeUTF8String_EmptyString(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, "", sanitizeUTF8String(""))
+}
+
+func TestSanitizeUTF8String_Unicode(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, "日本語テスト", sanitizeUTF8String("日本語テスト"))
+}
+
+// ===========================================================================
+// 9. HandleSpan helpers
+// ===========================================================================
+
+func TestHandleSpanBusinessErrorEvent_NilSpan(t *testing.T) {
+ t.Parallel()
+ assert.NotPanics(t, func() { HandleSpanBusinessErrorEvent(nil, "evt", assert.AnError) })
+}
+
+func TestHandleSpanBusinessErrorEvent_NilError(t *testing.T) {
+ t.Parallel()
+
+ tp := sdktrace.NewTracerProvider()
+ t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
+ _, span := tp.Tracer("test").Start(context.Background(), "op")
+ defer span.End()
+
+ assert.NotPanics(t, func() { HandleSpanBusinessErrorEvent(span, "evt", nil) })
+}
+
+func TestHandleSpanEvent_NilSpan(t *testing.T) {
+ t.Parallel()
+ assert.NotPanics(t, func() { HandleSpanEvent(nil, "evt") })
+}
+
+func TestHandleSpanError_NilSpan(t *testing.T) {
+ t.Parallel()
+ assert.NotPanics(t, func() { HandleSpanError(nil, "msg", assert.AnError) })
+}
+
+func TestHandleSpanError_NilError(t *testing.T) {
+ t.Parallel()
+
+ tp := sdktrace.NewTracerProvider()
+ t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
+ _, span := tp.Tracer("test").Start(context.Background(), "op")
+ defer span.End()
+
+ assert.NotPanics(t, func() { HandleSpanError(span, "msg", nil) })
+}
+
+// ===========================================================================
+// 10. SetSpanAttributesFromValue
+// ===========================================================================
+
+func TestSetSpanAttributesFromValue_NilSpan(t *testing.T) {
+ t.Parallel()
+ err := SetSpanAttributesFromValue(nil, "prefix", map[string]any{"k": "v"}, nil)
+ assert.NoError(t, err)
+}
+
+func TestSetSpanAttributesFromValue_NilValue(t *testing.T) {
+ t.Parallel()
+
+ tp := sdktrace.NewTracerProvider()
+ t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
+ _, span := tp.Tracer("test").Start(context.Background(), "op")
+ defer span.End()
+
+ err := SetSpanAttributesFromValue(span, "prefix", nil, nil)
+ assert.NoError(t, err)
+}
+
+// ===========================================================================
+// 11. BuildAttributesFromValue with redactor
+// ===========================================================================
+
+func TestBuildAttributesFromValue_WithRedactor(t *testing.T) {
+ t.Parallel()
+
+ r := NewDefaultRedactor()
+ attrs, err := BuildAttributesFromValue("req", map[string]any{
+ "username": "alice",
+ "password": "secret123",
+ }, r)
+ require.NoError(t, err)
+
+ m := attrsToMap(attrs)
+ assert.Equal(t, "alice", m["req.username"])
+ assert.NotEqual(t, "secret123", m["req.password"], "password should be redacted")
+}
+
+func TestBuildAttributesFromValue_StructInput(t *testing.T) {
+ t.Parallel()
+
+ type payload struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ }
+
+ attrs, err := BuildAttributesFromValue("obj", payload{ID: "123", Name: "test"}, nil)
+ require.NoError(t, err)
+
+ m := attrsToMap(attrs)
+ assert.Equal(t, "123", m["obj.id"])
+ assert.Equal(t, "test", m["obj.name"])
+}
+
+// ===========================================================================
+// 12. isNilShutdownable
+// ===========================================================================
+
+func TestIsNilShutdownable_UntypedNil(t *testing.T) {
+ t.Parallel()
+ assert.True(t, isNilShutdownable(nil))
+}
+
+func TestIsNilShutdownable_TypedNil(t *testing.T) {
+ t.Parallel()
+
+ var tp *sdktrace.TracerProvider
+ assert.True(t, isNilShutdownable(tp))
+}
+
+func TestIsNilShutdownable_ValidValue(t *testing.T) {
+ t.Parallel()
+
+ tp := sdktrace.NewTracerProvider()
+ t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
+ assert.False(t, isNilShutdownable(tp))
+}
+
+// ===========================================================================
+// 13. InjectGRPCContext key normalization
+// ===========================================================================
+
+func TestInjectGRPCContext_TraceparentKeyNormalization(t *testing.T) {
+ prev := otel.GetTextMapPropagator()
+ t.Cleanup(func() { otel.SetTextMapPropagator(prev) })
+ otel.SetTextMapPropagator(propagation.TraceContext{})
+
+ tp := sdktrace.NewTracerProvider()
+ t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
+
+ ctx, span := tp.Tracer("test").Start(context.Background(), "op")
+ defer span.End()
+
+ md := InjectGRPCContext(ctx, nil)
+ assert.NotEmpty(t, md.Get("traceparent"), "traceparent key should be lowercase")
+}
+
+// ===========================================================================
+// 14. Propagation round-trip
+// ===========================================================================
+
+func TestQueuePropagation_RoundTrip(t *testing.T) {
+ prev := otel.GetTextMapPropagator()
+ prevTP := otel.GetTracerProvider()
+ t.Cleanup(func() {
+ otel.SetTextMapPropagator(prev)
+ otel.SetTracerProvider(prevTP)
+ })
+
+ otel.SetTextMapPropagator(propagation.TraceContext{})
+ tp := sdktrace.NewTracerProvider()
+ otel.SetTracerProvider(tp)
+
+ ctx, span := tp.Tracer("test").Start(context.Background(), "producer")
+ defer span.End()
+
+ originalTraceID := span.SpanContext().TraceID().String()
+
+ queueHeaders := InjectQueueTraceContext(ctx)
+ assert.NotEmpty(t, queueHeaders)
+
+ consumerCtx := ExtractQueueTraceContext(context.Background(), queueHeaders)
+ extractedTraceID := GetTraceIDFromContext(consumerCtx)
+ assert.Equal(t, originalTraceID, extractedTraceID)
+
+ _ = tp.Shutdown(context.Background())
+}
+
+func TestHTTPPropagation_InjectAndVerify(t *testing.T) {
+ prev := otel.GetTextMapPropagator()
+ prevTP := otel.GetTracerProvider()
+ t.Cleanup(func() {
+ otel.SetTextMapPropagator(prev)
+ otel.SetTracerProvider(prevTP)
+ })
+
+ otel.SetTextMapPropagator(propagation.TraceContext{})
+ tp := sdktrace.NewTracerProvider()
+ otel.SetTracerProvider(tp)
+
+ ctx, span := tp.Tracer("test").Start(context.Background(), "http-req")
+ defer span.End()
+
+ headers := make(map[string][]string)
+ InjectHTTPContext(ctx, headers)
+ assert.NotEmpty(t, headers["Traceparent"])
+
+ _ = tp.Shutdown(context.Background())
+}
+
+// ===========================================================================
+// 15. buildShutdownHandlers
+// ===========================================================================
+
+func TestBuildShutdownHandlers_NoComponents(t *testing.T) {
+ t.Parallel()
+
+ shutdown, shutdownCtx := buildShutdownHandlers(log.NewNop())
+ assert.NotPanics(t, func() { shutdown() })
+
+ err := shutdownCtx(context.Background())
+ assert.NoError(t, err)
+}
+
+func TestBuildShutdownHandlers_WithProviders(t *testing.T) {
+ t.Parallel()
+
+ tp := sdktrace.NewTracerProvider()
+ shutdown, shutdownCtx := buildShutdownHandlers(log.NewNop(), tp)
+
+ err := shutdownCtx(context.Background())
+ assert.NoError(t, err)
+
+ assert.NotPanics(t, func() { shutdown() })
+}
+
+func TestBuildShutdownHandlers_NilComponents(t *testing.T) {
+ t.Parallel()
+
+ shutdown, shutdownCtx := buildShutdownHandlers(log.NewNop(), nil)
+ assert.NotPanics(t, func() { shutdown() })
+
+ err := shutdownCtx(context.Background())
+ assert.NoError(t, err)
+}
+
+func TestBuildShutdownHandlers_TypedNilProvider(t *testing.T) {
+ t.Parallel()
+
+ var tp *sdktrace.TracerProvider
+ shutdown, shutdownCtx := buildShutdownHandlers(log.NewNop(), tp)
+ assert.NotPanics(t, func() { shutdown() })
+
+ err := shutdownCtx(context.Background())
+ assert.NoError(t, err)
+}
+
+// ===========================================================================
+// 16. HandleSpan helpers with real spans
+// ===========================================================================
+
+func TestHandleSpanBusinessErrorEvent_WithSpan(t *testing.T) {
+ t.Parallel()
+
+ exporter := tracetest.NewInMemoryExporter()
+ tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
+ t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
+ _, span := tp.Tracer("test").Start(context.Background(), "op")
+
+ HandleSpanBusinessErrorEvent(span, "business_error", assert.AnError)
+ span.End()
+
+ spans := exporter.GetSpans()
+ require.Len(t, spans, 1)
+ require.NotEmpty(t, spans[0].Events, "business error event must be recorded")
+ assert.Equal(t, "business_error", spans[0].Events[0].Name)
+ assert.Equal(t, codes.Unset, spans[0].Status.Code, "business error must not set ERROR status")
+}
+
+func TestHandleSpanEvent_WithSpan(t *testing.T) {
+ t.Parallel()
+
+ exporter := tracetest.NewInMemoryExporter()
+ tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
+ t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
+ _, span := tp.Tracer("test").Start(context.Background(), "op")
+
+ HandleSpanEvent(span, "my_event", attribute.String("key", "value"))
+ span.End()
+
+ spans := exporter.GetSpans()
+ require.Len(t, spans, 1)
+ require.NotEmpty(t, spans[0].Events, "event must be recorded on span")
+ assert.Equal(t, "my_event", spans[0].Events[0].Name)
+}
+
+func TestHandleSpanError_WithSpan(t *testing.T) {
+ t.Parallel()
+
+ exporter := tracetest.NewInMemoryExporter()
+ tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
+ t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
+ _, span := tp.Tracer("test").Start(context.Background(), "op")
+
+ HandleSpanError(span, "something failed", assert.AnError)
+ span.End()
+
+ spans := exporter.GetSpans()
+ require.Len(t, spans, 1)
+ assert.Equal(t, codes.Error, spans[0].Status.Code, "HandleSpanError must set ERROR status")
+ assert.Contains(t, spans[0].Status.Description, "something failed")
+}
+
+func TestHandleSpanError_EmptyMessage(t *testing.T) {
+ t.Parallel()
+
+ exporter := tracetest.NewInMemoryExporter()
+ tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
+ t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
+ _, span := tp.Tracer("test").Start(context.Background(), "op")
+
+ HandleSpanError(span, "", assert.AnError)
+ span.End()
+
+ spans := exporter.GetSpans()
+ require.Len(t, spans, 1)
+ assert.Equal(t, codes.Error, spans[0].Status.Code)
+ assert.False(t, strings.HasPrefix(spans[0].Status.Description, ": "),
+ "empty message must not produce leading ': ' in status description")
+}
+
+// ===========================================================================
+// 17. ShutdownTelemetry (non-nil) exercises error branch
+// ===========================================================================
+
+func TestTelemetry_ShutdownTelemetry_NonNil(t *testing.T) {
+ t.Parallel()
+
+ tl := newDisabledTelemetry(t)
+ assert.NotPanics(t, func() { tl.ShutdownTelemetry() })
+}
+
+// ===========================================================================
+// 18. InjectGRPCContext / ExtractGRPCContext tracestate normalization
+// ===========================================================================
+
+func TestInjectGRPCContext_TracestateNormalization(t *testing.T) {
+ prev := otel.GetTextMapPropagator()
+ t.Cleanup(func() { otel.SetTextMapPropagator(prev) })
+ otel.SetTextMapPropagator(propagation.TraceContext{})
+
+ traceID, _ := trace.TraceIDFromHex("00112233445566778899aabbccddeeff")
+ spanID, _ := trace.SpanIDFromHex("0123456789abcdef")
+ ts := trace.TraceState{}
+ ts, _ = ts.Insert("vendor", "val")
+
+ sc := trace.NewSpanContext(trace.SpanContextConfig{
+ TraceID: traceID,
+ SpanID: spanID,
+ TraceFlags: trace.FlagsSampled,
+ TraceState: ts,
+ Remote: true,
+ })
+ ctx := trace.ContextWithSpanContext(context.Background(), sc)
+
+ md := InjectGRPCContext(ctx, nil)
+ assert.NotEmpty(t, md.Get("traceparent"))
+ assert.NotEmpty(t, md.Get("tracestate"))
+ _, hasPascal := md["Traceparent"]
+ assert.False(t, hasPascal)
+}
+
+func TestExtractGRPCContext_TracestateNormalization(t *testing.T) {
+ prev := otel.GetTextMapPropagator()
+ t.Cleanup(func() { otel.SetTextMapPropagator(prev) })
+ otel.SetTextMapPropagator(propagation.TraceContext{})
+
+ md := metadata.MD{
+ "traceparent": {"00-00112233445566778899aabbccddeeff-0123456789abcdef-01"},
+ "tracestate": {"vendor=val"},
+ }
+ ctx := ExtractGRPCContext(context.Background(), md)
+ span := trace.SpanFromContext(ctx)
+ assert.Equal(t, "00112233445566778899aabbccddeeff", span.SpanContext().TraceID().String())
+}
+
+// ===========================================================================
+// 19. Processor OnStart/OnEnd via tracer pipeline
+// ===========================================================================
+
+func TestAttrBagSpanProcessor_OnStartOnEnd_WithTracer(t *testing.T) {
+ t.Parallel()
+
+ tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(AttrBagSpanProcessor{}))
+ t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
+
+ ctx, span := tp.Tracer("test").Start(context.Background(), "op")
+ defer span.End()
+ assert.NotNil(t, ctx)
+}
+
+func TestRedactingAttrBagSpanProcessor_OnStartOnEnd_WithTracer(t *testing.T) {
+ t.Parallel()
+
+ p := RedactingAttrBagSpanProcessor{Redactor: NewDefaultRedactor()}
+ tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(p))
+ t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
+
+ ctx, span := tp.Tracer("test").Start(context.Background(), "op")
+ defer span.End()
+ assert.NotNil(t, ctx)
+}
+
+func TestAttrBagSpanProcessor_OnStart_WithContextAttributes(t *testing.T) {
+ t.Parallel()
+
+ exporter := tracetest.NewInMemoryExporter()
+ tp := sdktrace.NewTracerProvider(
+ sdktrace.WithSpanProcessor(AttrBagSpanProcessor{}),
+ sdktrace.WithSyncer(exporter),
+ )
+ t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
+
+ ctx := observability.ContextWithSpanAttributes(context.Background(), attribute.String("app.request.id", "r1"))
+ _, span := tp.Tracer("test").Start(ctx, "op")
+ span.End()
+
+ spans := exporter.GetSpans()
+ require.Len(t, spans, 1)
+ found := false
+ for _, a := range spans[0].Attributes {
+ if a.Key == "app.request.id" && a.Value.AsString() == "r1" {
+ found = true
+ }
+ }
+ assert.True(t, found, "span must contain app.request.id=r1 from context bag")
+}
+
+func TestRedactingAttrBagSpanProcessor_OnStart_WithContextAttributes(t *testing.T) {
+ t.Parallel()
+
+ p := RedactingAttrBagSpanProcessor{Redactor: NewDefaultRedactor()}
+ exporter := tracetest.NewInMemoryExporter()
+ tp := sdktrace.NewTracerProvider(
+ sdktrace.WithSpanProcessor(p),
+ sdktrace.WithSyncer(exporter),
+ )
+ t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
+
+ ctx := observability.ContextWithSpanAttributes(context.Background(),
+ attribute.String("app.request.id", "r1"),
+ attribute.String("user.password", "secret"),
+ )
+ _, span := tp.Tracer("test").Start(ctx, "op")
+ span.End()
+
+ spans := exporter.GetSpans()
+ require.Len(t, spans, 1)
+ for _, a := range spans[0].Attributes {
+ if a.Key == "app.request.id" {
+ assert.Equal(t, "r1", a.Value.AsString(), "non-sensitive field should pass through")
+ }
+ if a.Key == "user.password" {
+ assert.NotEqual(t, "secret", a.Value.AsString(), "sensitive field should be redacted")
+ }
+ }
+}
+
+func TestRedactingAttrBagSpanProcessor_OnStart_NilRedactor(t *testing.T) {
+ t.Parallel()
+
+ p := RedactingAttrBagSpanProcessor{Redactor: nil}
+ tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(p))
+ t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
+
+ ctx, span := tp.Tracer("test").Start(context.Background(), "op")
+ defer span.End()
+ assert.NotNil(t, ctx)
+}
+
+// ===========================================================================
+// 20. flattenAttributes edge case: default branch
+// ===========================================================================
+
+func TestFlattenAttributes_DefaultBranch(t *testing.T) {
+ t.Parallel()
+
+ type custom struct{ X int }
+ var attrs []attribute.KeyValue
+ flattenAttributes(&attrs, "key", custom{X: 42}, 0)
+ require.Len(t, attrs, 1)
+ assert.Equal(t, "key", string(attrs[0].Key))
+ assert.Contains(t, attrs[0].Value.AsString(), "42")
+}
+
+// ===========================================================================
+// 21. newResource coverage
+// ===========================================================================
+
+func TestNewResource(t *testing.T) {
+ t.Parallel()
+
+ cfg := &TelemetryConfig{
+ ServiceName: "svc",
+ ServiceVersion: "1.0",
+ DeploymentEnv: "test",
+ }
+ r := cfg.newResource()
+ assert.NotNil(t, r)
+}
+
+// ===========================================================================
+// 22. BuildAttributesFromValue error path
+// ===========================================================================
+
+func TestBuildAttributesFromValue_UnmarshalableValue(t *testing.T) {
+ t.Parallel()
+
+ ch := make(chan int)
+ attrs, err := BuildAttributesFromValue("prefix", ch, nil)
+ assert.Error(t, err)
+ assert.Nil(t, attrs)
+}
+
+// ===========================================================================
+// helpers
+// ===========================================================================
+
+func attrsToMap(attrs []attribute.KeyValue) map[string]string {
+ m := make(map[string]string, len(attrs))
+ for _, a := range attrs {
+ m[string(a.Key)] = a.Value.Emit()
+ }
+
+ return m
+}
diff --git a/tracing/processor.go b/tracing/processor.go
new file mode 100644
index 0000000..826acf7
--- /dev/null
+++ b/tracing/processor.go
@@ -0,0 +1,92 @@
+package tracing
+
+import (
+ "context"
+ "strings"
+
+ observability "github.com/LerianStudio/lib-observability"
+ "go.opentelemetry.io/otel/attribute"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+)
+
+// ---- SpanProcessor that applies the AttrBag to every new span ----
+
+// AttrBagSpanProcessor copies request-scoped attributes from context into every span at start.
+type AttrBagSpanProcessor struct{}
+
+// RedactingAttrBagSpanProcessor copies request attributes and applies redaction rules by key.
+type RedactingAttrBagSpanProcessor struct {
+ Redactor *Redactor
+}
+
+// OnStart applies request-scoped context attributes to newly started spans.
+func (AttrBagSpanProcessor) OnStart(ctx context.Context, s sdktrace.ReadWriteSpan) {
+ if kv := observability.AttributesFromContext(ctx); len(kv) > 0 {
+ s.SetAttributes(kv...)
+ }
+}
+
+// OnStart applies request-scoped attributes and redacts sensitive values before writing to span.
+func (p RedactingAttrBagSpanProcessor) OnStart(ctx context.Context, s sdktrace.ReadWriteSpan) {
+ kv := observability.AttributesFromContext(ctx)
+ if len(kv) == 0 {
+ return
+ }
+
+ if p.Redactor != nil {
+ kv = redactAttributesByKey(kv, p.Redactor)
+ }
+
+ s.SetAttributes(kv...)
+}
+
+// OnEnd is a no-op for this processor.
+func (AttrBagSpanProcessor) OnEnd(sdktrace.ReadOnlySpan) {}
+
+// OnEnd is a no-op for this processor.
+func (RedactingAttrBagSpanProcessor) OnEnd(sdktrace.ReadOnlySpan) {}
+
+// Shutdown is a no-op and always returns nil.
+func (AttrBagSpanProcessor) Shutdown(context.Context) error { return nil }
+
+// Shutdown is a no-op and always returns nil.
+func (RedactingAttrBagSpanProcessor) Shutdown(context.Context) error { return nil }
+
+// ForceFlush is a no-op and always returns nil.
+func (AttrBagSpanProcessor) ForceFlush(context.Context) error { return nil }
+
+// ForceFlush is a no-op and always returns nil.
+func (RedactingAttrBagSpanProcessor) ForceFlush(context.Context) error { return nil }
+
+func redactAttributesByKey(attrs []attribute.KeyValue, redactor *Redactor) []attribute.KeyValue {
+ if redactor == nil {
+ return attrs
+ }
+
+ redacted := make([]attribute.KeyValue, 0, len(attrs))
+ for _, attr := range attrs {
+ key := string(attr.Key)
+
+ fieldName := key
+ if idx := strings.LastIndex(key, "."); idx >= 0 && idx+1 < len(key) {
+ fieldName = key[idx+1:]
+ }
+
+ action, ok := redactor.actionFor(key, fieldName)
+ if !ok {
+ redacted = append(redacted, attr)
+ continue
+ }
+
+ switch action {
+ case RedactionDrop:
+ continue
+ case RedactionHash:
+ redacted = append(redacted, attribute.String(string(attr.Key), redactor.hashString(attr.Value.Emit())))
+ default:
+ redacted = append(redacted, attribute.String(string(attr.Key), redactor.maskValue))
+ }
+ }
+
+ return redacted
+}
diff --git a/tracing/v2_test.go b/tracing/v2_test.go
new file mode 100644
index 0000000..406b5f6
--- /dev/null
+++ b/tracing/v2_test.go
@@ -0,0 +1,176 @@
+//go:build unit
+
+package tracing
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/LerianStudio/lib-observability/log"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/log/global"
+ "go.opentelemetry.io/otel/propagation"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ "go.opentelemetry.io/otel/sdk/trace/tracetest"
+ oteltrace "go.opentelemetry.io/otel/trace"
+ "google.golang.org/grpc/metadata"
+)
+
+func TestNewTelemetry_Disabled(t *testing.T) {
+ tl, err := NewTelemetry(TelemetryConfig{
+ LibraryName: "test-lib",
+ ServiceName: "test-service",
+ ServiceVersion: "1.0.0",
+ DeploymentEnv: "test",
+ EnableTelemetry: false,
+ Logger: &log.NopLogger{},
+ })
+ require.NoError(t, err)
+ require.NotNil(t, tl)
+ assert.NotNil(t, tl.TracerProvider)
+ assert.NotNil(t, tl.MeterProvider)
+ assert.NotNil(t, tl.MetricsFactory)
+}
+
+func TestSetSpanAttributesFromValue_FlattensAndRedacts(t *testing.T) {
+ exporter := tracetest.NewInMemoryExporter()
+ tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
+ tracer := tp.Tracer("test")
+
+ _, span := tracer.Start(context.Background(), "test-span")
+ err := SetSpanAttributesFromValue(span, "request", map[string]any{
+ "user": map[string]any{
+ "id": "u1",
+ "password": "top-secret",
+ },
+ "amount": 12.3,
+ }, NewDefaultRedactor())
+ require.NoError(t, err)
+ span.End()
+
+ spans := exporter.GetSpans()
+ require.Len(t, spans, 1)
+
+ attrs := spans[0].Attributes
+ find := func(key string) string {
+ for _, a := range attrs {
+ if string(a.Key) == key {
+ return a.Value.AsString()
+ }
+ }
+ return ""
+ }
+
+ assert.Equal(t, "u1", find("request.user.id"))
+ assert.NotEmpty(t, find("request.user.password"))
+ assert.NotEqual(t, "top-secret", find("request.user.password"))
+
+ if err := tp.Shutdown(context.Background()); err != nil {
+ t.Errorf("tp.Shutdown failed: %v", err)
+ }
+}
+
+func TestPropagation_HTTP_GRPC_Queue(t *testing.T) {
+ prevPropagator := otel.GetTextMapPropagator()
+ prevTracerProvider := otel.GetTracerProvider()
+ t.Cleanup(func() {
+ otel.SetTextMapPropagator(prevPropagator)
+ otel.SetTracerProvider(prevTracerProvider)
+ })
+
+ otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
+
+ tp := sdktrace.NewTracerProvider()
+ otel.SetTracerProvider(tp)
+ tracer := tp.Tracer("test")
+
+ ctx, span := tracer.Start(context.Background(), "root")
+ defer span.End()
+
+ headers := map[string][]string{}
+ InjectHTTPContext(ctx, headers)
+ assert.NotEmpty(t, headers["Traceparent"])
+
+ md := InjectGRPCContext(ctx, nil)
+ assert.NotEmpty(t, md.Get("traceparent"))
+
+ queueHeaders := InjectQueueTraceContext(ctx)
+ extracted := ExtractQueueTraceContext(context.Background(), queueHeaders)
+ assert.Equal(t, span.SpanContext().TraceID().String(), oteltrace.SpanFromContext(extracted).SpanContext().TraceID().String())
+
+ if err := tp.Shutdown(context.Background()); err != nil {
+ t.Errorf("tp.Shutdown failed: %v", err)
+ }
+}
+
+func TestApplyGlobalsRestoresProviders(t *testing.T) {
+ prevPropagator := otel.GetTextMapPropagator()
+ prevTracerProvider := otel.GetTracerProvider()
+ prevMeterProvider := otel.GetMeterProvider()
+ prevLoggerProvider := global.GetLoggerProvider()
+ t.Cleanup(func() {
+ otel.SetTextMapPropagator(prevPropagator)
+ otel.SetTracerProvider(prevTracerProvider)
+ otel.SetMeterProvider(prevMeterProvider)
+ global.SetLoggerProvider(prevLoggerProvider)
+ })
+
+ tl, err := NewTelemetry(TelemetryConfig{
+ LibraryName: "test-lib",
+ ServiceName: "test-service",
+ ServiceVersion: "1.0.0",
+ DeploymentEnv: "test",
+ EnableTelemetry: false,
+ Logger: &log.NopLogger{},
+ })
+ require.NoError(t, err)
+
+ require.NoError(t, tl.ApplyGlobals())
+
+ assert.Same(t, tl.TracerProvider, otel.GetTracerProvider())
+ assert.Same(t, tl.MeterProvider, otel.GetMeterProvider())
+ assert.Same(t, tl.LoggerProvider, global.GetLoggerProvider())
+}
+
+func TestObfuscateStruct_Actions(t *testing.T) {
+ redactor, err := NewRedactor([]RedactionRule{
+ {FieldPattern: `(?i)^password$`, Action: RedactionMask},
+ {FieldPattern: `(?i)^document$`, Action: RedactionHash},
+ {PathPattern: `(?i)^session\.token$`, FieldPattern: `(?i)^token$`, Action: RedactionDrop},
+ }, "***")
+ require.NoError(t, err)
+
+ payload := map[string]any{
+ "password": "secret",
+ "document": "123456789",
+ "session": map[string]any{"token": "tok_abc"},
+ }
+
+ obfuscated, err := ObfuscateStruct(payload, redactor)
+ require.NoError(t, err)
+
+ b, err := json.Marshal(obfuscated)
+ require.NoError(t, err)
+
+ var decoded map[string]any
+ require.NoError(t, json.Unmarshal(b, &decoded))
+ assert.Equal(t, "***", decoded["password"])
+ assert.Contains(t, decoded["document"], "sha256:")
+ assert.NotContains(t, decoded["session"], "token")
+}
+
+func TestHandleSpanHelpers_NoPanicsOnNil(t *testing.T) {
+ var span oteltrace.Span
+ assert.NotPanics(t, func() {
+ HandleSpanEvent(span, "event", attribute.String("k", "v"))
+ HandleSpanBusinessErrorEvent(span, "event", assert.AnError)
+ HandleSpanError(span, "msg", assert.AnError)
+ })
+ assert.NotPanics(t, func() {
+ _ = ExtractGRPCContext(context.Background(), metadata.MD{})
+ })
+}
diff --git a/zap/doc.go b/zap/doc.go
new file mode 100644
index 0000000..4b66b6c
--- /dev/null
+++ b/zap/doc.go
@@ -0,0 +1,4 @@
+// Package zap provides a zap adapter implementing the Logger interface with automatic
+// trace_id and span_id injection into every log entry. Bridges zap output to the
+// OpenTelemetry Logs SDK via otelzap for unified log collection through the OTLP pipeline.
+package zap
diff --git a/zap/injector.go b/zap/injector.go
new file mode 100644
index 0000000..7662c9d
--- /dev/null
+++ b/zap/injector.go
@@ -0,0 +1,153 @@
+package zap
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+
+ "go.opentelemetry.io/contrib/bridges/otelzap"
+ "go.uber.org/zap"
+ "go.uber.org/zap/zapcore"
+)
+
+const (
+ callerSkipFrames = 1
+ encodingConsole = "console"
+)
+
+// Environment controls the baseline logger profile.
+type Environment string
+
+const (
+ // EnvironmentProduction enables production-safe logging defaults.
+ EnvironmentProduction Environment = "production"
+ // EnvironmentStaging enables staging-safe logging defaults.
+ EnvironmentStaging Environment = "staging"
+ // EnvironmentUAT enables UAT-safe logging defaults.
+ EnvironmentUAT Environment = "uat"
+ // EnvironmentDevelopment enables verbose development logging defaults.
+ EnvironmentDevelopment Environment = "development"
+ // EnvironmentLocal enables verbose local-development logging defaults.
+ EnvironmentLocal Environment = "local"
+)
+
+// Config contains all required logger initialization inputs.
+type Config struct {
+ Environment Environment
+ Level string
+ OTelLibraryName string
+}
+
+func (c Config) validate() error {
+ if c.OTelLibraryName == "" {
+ return errors.New("OTelLibraryName is required")
+ }
+
+ switch c.Environment {
+ case EnvironmentProduction, EnvironmentStaging, EnvironmentUAT, EnvironmentDevelopment, EnvironmentLocal:
+ return nil
+ default:
+ return fmt.Errorf("invalid environment %q", c.Environment)
+ }
+}
+
+// New creates a structured logger from the given configuration.
+//
+// The returned Logger implements log.Logger and stores the runtime-adjustable
+// level handle internally. Use Logger.Level() to access it.
+func New(cfg Config) (*Logger, error) {
+ if err := cfg.validate(); err != nil {
+ return nil, fmt.Errorf("invalid zap config: %w", err)
+ }
+
+ baseConfig := buildConfigByEnvironment(cfg.Environment)
+
+ level, err := resolveLevel(cfg)
+ if err != nil {
+ return nil, err
+ }
+
+ baseConfig.Level = level
+ baseConfig.DisableStacktrace = true
+
+ coreOptions := []zap.Option{
+ zap.AddCallerSkip(callerSkipFrames),
+ zap.WrapCore(func(core zapcore.Core) zapcore.Core {
+ return zapcore.NewTee(core, otelzap.NewCore(cfg.OTelLibraryName))
+ }),
+ }
+
+ built, err := baseConfig.Build(coreOptions...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to build logger: %w", err)
+ }
+
+ return &Logger{
+ logger: built,
+ atomicLevel: level,
+ consoleEncoding: baseConfig.Encoding == encodingConsole,
+ }, nil
+}
+
+func resolveLevel(cfg Config) (zap.AtomicLevel, error) {
+ levelStr := cfg.Level
+ if strings.TrimSpace(levelStr) == "" {
+ levelStr = strings.TrimSpace(os.Getenv("LOG_LEVEL"))
+ }
+
+ if levelStr != "" {
+ var parsed zapcore.Level
+ if err := parsed.Set(levelStr); err != nil {
+ return zap.AtomicLevel{}, fmt.Errorf("invalid level %q: %w", levelStr, err)
+ }
+
+ return zap.NewAtomicLevelAt(parsed), nil
+ }
+
+ if cfg.Environment == EnvironmentDevelopment || cfg.Environment == EnvironmentLocal {
+ return zap.NewAtomicLevelAt(zapcore.DebugLevel), nil
+ }
+
+ return zap.NewAtomicLevelAt(zapcore.InfoLevel), nil
+}
+
+func buildConfigByEnvironment(environment Environment) zap.Config {
+ encoding := resolveEncoding(environment)
+
+ if environment == EnvironmentDevelopment || environment == EnvironmentLocal {
+ cfg := zap.NewDevelopmentConfig()
+ cfg.Encoding = encoding
+ cfg.EncoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
+ cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
+ cfg.EncoderConfig.TimeKey = "timestamp"
+
+ if encoding == encodingConsole {
+ cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
+ }
+
+ return cfg
+ }
+
+ cfg := zap.NewProductionConfig()
+ cfg.Encoding = encoding
+ cfg.EncoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
+ cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
+ cfg.EncoderConfig.TimeKey = "timestamp"
+
+ return cfg
+}
+
+func resolveEncoding(environment Environment) string {
+ if enc := strings.TrimSpace(os.Getenv("LOG_ENCODING")); enc != "" {
+ if enc == "json" || enc == encodingConsole {
+ return enc
+ }
+ }
+
+ if environment == EnvironmentDevelopment || environment == EnvironmentLocal {
+ return encodingConsole
+ }
+
+ return "json"
+}
diff --git a/zap/injector_test.go b/zap/injector_test.go
new file mode 100644
index 0000000..d24df19
--- /dev/null
+++ b/zap/injector_test.go
@@ -0,0 +1,155 @@
+//go:build unit
+
+package zap
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap/zapcore"
+)
+
+func TestNewRejectsMissingOTelLibraryName(t *testing.T) {
+ t.Parallel()
+
+ _, err := New(Config{Environment: EnvironmentProduction})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "OTelLibraryName is required")
+}
+
+func TestNewRejectsInvalidEnvironment(t *testing.T) {
+ t.Parallel()
+
+ _, err := New(Config{Environment: Environment("banana"), OTelLibraryName: "svc"})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid environment")
+}
+
+func TestNewAppliesEnvironmentDefaultLevel(t *testing.T) {
+ t.Parallel()
+
+ logger, err := New(Config{Environment: EnvironmentDevelopment, OTelLibraryName: "svc"})
+ require.NoError(t, err)
+ assert.Equal(t, zapcore.DebugLevel, logger.Level().Level())
+
+ logger, err = New(Config{Environment: EnvironmentProduction, OTelLibraryName: "svc"})
+ require.NoError(t, err)
+ assert.Equal(t, zapcore.InfoLevel, logger.Level().Level())
+}
+
+func TestNewAppliesCustomLevel(t *testing.T) {
+ t.Parallel()
+
+ logger, err := New(Config{Environment: EnvironmentProduction, OTelLibraryName: "svc", Level: "error"})
+ require.NoError(t, err)
+ assert.Equal(t, zapcore.ErrorLevel, logger.Level().Level())
+}
+
+func TestNewRejectsInvalidCustomLevel(t *testing.T) {
+ t.Parallel()
+
+ _, err := New(Config{Environment: EnvironmentProduction, OTelLibraryName: "svc", Level: "invalid"})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid level")
+}
+
+func TestCallerAttributionPointsToCallSite(t *testing.T) {
+ t.Parallel()
+
+ logger, err := New(Config{
+ Environment: EnvironmentDevelopment,
+ OTelLibraryName: "test-caller",
+ })
+ require.NoError(t, err)
+
+ raw := logger.Raw()
+ require.NotNil(t, raw, "Raw() should return the underlying zap logger")
+}
+
+func TestNewWithLocalEnvironment(t *testing.T) {
+ t.Parallel()
+
+ logger, err := New(Config{Environment: EnvironmentLocal, OTelLibraryName: "svc"})
+ require.NoError(t, err)
+ require.NotNil(t, logger)
+ assert.Equal(t, zapcore.DebugLevel, logger.Level().Level())
+}
+
+func TestNewWithStagingEnvironment(t *testing.T) {
+ t.Parallel()
+
+ logger, err := New(Config{Environment: EnvironmentStaging, OTelLibraryName: "svc"})
+ require.NoError(t, err)
+ require.NotNil(t, logger)
+ assert.Equal(t, zapcore.InfoLevel, logger.Level().Level())
+}
+
+func TestNewWithUATEnvironment(t *testing.T) {
+ t.Parallel()
+
+ logger, err := New(Config{Environment: EnvironmentUAT, OTelLibraryName: "svc"})
+ require.NoError(t, err)
+ require.NotNil(t, logger)
+ assert.Equal(t, zapcore.InfoLevel, logger.Level().Level())
+}
+
+func TestResolveLevelEmptyForProductionDefaultsToInfo(t *testing.T) {
+ t.Parallel()
+
+ level, err := resolveLevel(Config{Environment: EnvironmentProduction, Level: ""})
+ require.NoError(t, err)
+ assert.Equal(t, zapcore.InfoLevel, level.Level())
+}
+
+func TestResolveLevelEmptyForLocalDefaultsToDebug(t *testing.T) {
+ t.Parallel()
+
+ level, err := resolveLevel(Config{Environment: EnvironmentLocal, Level: ""})
+ require.NoError(t, err)
+ assert.Equal(t, zapcore.DebugLevel, level.Level())
+}
+
+func TestBuildConfigByEnvironmentDev(t *testing.T) {
+ t.Setenv("LOG_ENCODING", "")
+
+ cfg := buildConfigByEnvironment(EnvironmentDevelopment)
+ assert.Equal(t, "console", cfg.Encoding)
+ assert.True(t, cfg.Development)
+}
+
+func TestBuildConfigByEnvironmentProd(t *testing.T) {
+ t.Setenv("LOG_ENCODING", "")
+
+ cfg := buildConfigByEnvironment(EnvironmentProduction)
+ assert.Equal(t, "json", cfg.Encoding)
+ assert.False(t, cfg.Development)
+}
+
+func TestResolveEncodingFromEnvVar(t *testing.T) {
+ t.Setenv("LOG_ENCODING", "json")
+ assert.Equal(t, "json", resolveEncoding(EnvironmentLocal))
+
+ t.Setenv("LOG_ENCODING", "console")
+ assert.Equal(t, "console", resolveEncoding(EnvironmentProduction))
+
+ t.Setenv("LOG_ENCODING", "invalid")
+ assert.Equal(t, "console", resolveEncoding(EnvironmentLocal))
+ assert.Equal(t, "json", resolveEncoding(EnvironmentProduction))
+}
+
+func TestResolveLevelFromEnvVar(t *testing.T) {
+ t.Setenv("LOG_LEVEL", "warn")
+
+ level, err := resolveLevel(Config{Environment: EnvironmentProduction, Level: ""})
+ require.NoError(t, err)
+ assert.Equal(t, zapcore.WarnLevel, level.Level())
+}
+
+func TestResolveLevelConfigOverridesEnvVar(t *testing.T) {
+ t.Setenv("LOG_LEVEL", "warn")
+
+ level, err := resolveLevel(Config{Environment: EnvironmentProduction, Level: "error"})
+ require.NoError(t, err)
+ assert.Equal(t, zapcore.ErrorLevel, level.Level(), "Config.Level should take precedence over LOG_LEVEL env var")
+}
diff --git a/zap/zap.go b/zap/zap.go
new file mode 100644
index 0000000..70fc24b
--- /dev/null
+++ b/zap/zap.go
@@ -0,0 +1,296 @@
+package zap
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ logpkg "github.com/LerianStudio/lib-observability/log"
+ "github.com/LerianStudio/lib-observability/redaction"
+ "github.com/LerianStudio/lib-observability/runtime"
+ "go.opentelemetry.io/otel/trace"
+ "go.uber.org/zap"
+ "go.uber.org/zap/zapcore"
+)
+
+// Field is a typed structured logging field (zap alias kept for convenience methods).
+type Field = zap.Field
+
+// Logger is a strict structured logger that implements log.Logger.
+//
+// It intentionally does not expose printf/line/fatal helpers.
+type Logger struct {
+ logger *zap.Logger
+ atomicLevel zap.AtomicLevel
+ // consoleEncoding is true when the logger uses console encoding.
+ // When true, messages are sanitized to prevent CWE-117 log injection,
+ // since console encoding does not inherently escape control characters
+ // the way JSON encoding does.
+ consoleEncoding bool
+}
+
+// Compile-time assertion: *Logger implements logpkg.Logger.
+var _ logpkg.Logger = (*Logger)(nil)
+
+func (l *Logger) must() *zap.Logger {
+ if l == nil || l.logger == nil {
+ return zap.NewNop()
+ }
+
+ return l.logger
+}
+
+// ---------------------------------------------------------------------------
+// log.Logger interface methods
+// ---------------------------------------------------------------------------
+
+// Log implements log.Logger. It dispatches to the appropriate zap level.
+// If ctx carries an active OpenTelemetry span, trace_id and span_id are
+// automatically appended so logs correlate with distributed traces.
+//
+// Unknown levels are treated as LevelInfo (consistent with GoLogger policy).
+func (l *Logger) Log(ctx context.Context, level logpkg.Level, msg string, fields ...logpkg.Field) {
+ zapFields := logFieldsToZap(fields)
+
+ if ctx != nil {
+ if sc := trace.SpanFromContext(ctx).SpanContext(); sc.IsValid() {
+ zapFields = append(zapFields,
+ zap.String("trace_id", sc.TraceID().String()),
+ zap.String("span_id", sc.SpanID().String()),
+ )
+ }
+ }
+
+ // Sanitize message for console encoding (CWE-117 prevention).
+ // JSON encoding handles this via its built-in escaping.
+ safeMsg := l.sanitizeConsoleMsg(msg)
+
+ switch level {
+ case logpkg.LevelDebug:
+ l.must().Debug(safeMsg, zapFields...)
+ case logpkg.LevelInfo:
+ l.must().Info(safeMsg, zapFields...)
+ case logpkg.LevelWarn:
+ l.must().Warn(safeMsg, zapFields...)
+ case logpkg.LevelError:
+ l.must().Error(safeMsg, zapFields...)
+ default:
+ // Unknown level policy: treat as Info. This is consistent across both
+ // GoLogger and zap backends. See log.Level documentation.
+ l.must().Info(safeMsg, zapFields...)
+ }
+}
+
+// With returns a child logger with additional structured fields.
+//
+//nolint:ireturn
+func (l *Logger) With(fields ...logpkg.Field) logpkg.Logger {
+ if l == nil {
+ return &Logger{logger: zap.NewNop()}
+ }
+
+ return &Logger{
+ logger: l.must().With(logFieldsToZap(fields)...),
+ atomicLevel: l.atomicLevel,
+ consoleEncoding: l.consoleEncoding,
+ }
+}
+
+// WithGroup returns a child logger that nests subsequent fields under a namespace.
+// Empty group names are silently ignored, consistent with GoLogger behavior.
+//
+//nolint:ireturn
+func (l *Logger) WithGroup(name string) logpkg.Logger {
+ if l == nil {
+ return &Logger{logger: zap.NewNop()}
+ }
+
+ if strings.TrimSpace(name) == "" {
+ return l
+ }
+
+ return &Logger{
+ logger: l.must().With(zap.Namespace(name)),
+ atomicLevel: l.atomicLevel,
+ consoleEncoding: l.consoleEncoding,
+ }
+}
+
+// Enabled reports whether the logger would emit a log at the given level.
+func (l *Logger) Enabled(level logpkg.Level) bool {
+ return l.must().Core().Enabled(logLevelToZap(level))
+}
+
+// Sync flushes buffered logs, respecting context cancellation.
+func (l *Logger) Sync(ctx context.Context) error {
+ if ctx == nil {
+ return l.must().Sync()
+ }
+
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
+ done := make(chan error, 1)
+
+ go func() {
+ defer func() {
+ if r := recover(); r != nil {
+ runtime.HandlePanicValue(ctx, nil, r, "zap", "sync")
+
+ done <- fmt.Errorf("panic during logger sync: %v", r)
+ }
+ }()
+
+ done <- l.must().Sync()
+ }()
+
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case err := <-done:
+ return err
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Convenience methods (direct zap.Field access for performance-sensitive code)
+// ---------------------------------------------------------------------------
+
+// WithZapFields returns a child logger with additional zap.Field values.
+// Use this when working directly with zap fields for performance.
+func (l *Logger) WithZapFields(fields ...Field) *Logger {
+ if l == nil {
+ return &Logger{logger: zap.NewNop()}
+ }
+
+ return &Logger{
+ logger: l.must().With(fields...),
+ atomicLevel: l.atomicLevel,
+ consoleEncoding: l.consoleEncoding,
+ }
+}
+
+// Debug logs a message with debug severity.
+func (l *Logger) Debug(message string, fields ...Field) {
+ l.must().Debug(l.sanitizeConsoleMsg(message), fields...)
+}
+
+// Info logs a message with info severity.
+func (l *Logger) Info(message string, fields ...Field) {
+ l.must().Info(l.sanitizeConsoleMsg(message), fields...)
+}
+
+// Warn logs a message with warn severity.
+func (l *Logger) Warn(message string, fields ...Field) {
+ l.must().Warn(l.sanitizeConsoleMsg(message), fields...)
+}
+
+// Error logs a message with error severity.
+func (l *Logger) Error(message string, fields ...Field) {
+ l.must().Error(l.sanitizeConsoleMsg(message), fields...)
+}
+
+// Raw returns the underlying zap logger.
+func (l *Logger) Raw() *zap.Logger {
+ return l.must()
+}
+
+// Level returns the runtime-adjustable level handle for this logger.
+// On a nil receiver, a default AtomicLevel (info) is returned.
+func (l *Logger) Level() zap.AtomicLevel {
+ if l == nil {
+ return zap.NewAtomicLevel()
+ }
+
+ return l.atomicLevel
+}
+
+// Any creates a field with any value.
+func Any(key string, value any) Field {
+ return zap.Any(key, value)
+}
+
+// String creates a string field.
+func String(key, value string) Field {
+ return zap.String(key, value)
+}
+
+// Int creates an int field.
+func Int(key string, value int) Field {
+ return zap.Int(key, value)
+}
+
+// Bool creates a bool field.
+func Bool(key string, value bool) Field {
+ return zap.Bool(key, value)
+}
+
+// Duration creates a duration field.
+func Duration(key string, value time.Duration) Field {
+ return zap.Duration(key, value)
+}
+
+// ErrorField creates an error field.
+func ErrorField(err error) Field {
+ return zap.Error(err)
+}
+
+// ---------------------------------------------------------------------------
+// Internal conversion helpers
+// ---------------------------------------------------------------------------
+
+// logLevelToZap converts a log.Level to a zapcore.Level.
+func logLevelToZap(level logpkg.Level) zapcore.Level {
+ switch level {
+ case logpkg.LevelDebug:
+ return zapcore.DebugLevel
+ case logpkg.LevelInfo:
+ return zapcore.InfoLevel
+ case logpkg.LevelWarn:
+ return zapcore.WarnLevel
+ case logpkg.LevelError:
+ return zapcore.ErrorLevel
+ default:
+ return zapcore.InfoLevel
+ }
+}
+
+// redactedValue is the placeholder used for sensitive field values in log output.
+const redactedValue = "[REDACTED]"
+
+// consoleControlCharReplacer neutralizes control characters that can split log
+// lines or forge entries in console-encoded output (CWE-117). JSON encoding
+// handles this automatically via its escaping rules.
+var consoleControlCharReplacer = strings.NewReplacer(
+ "\n", `\n`,
+ "\r", `\r`,
+ "\t", `\t`,
+ "\x00", `\0`,
+)
+
+// sanitizeConsoleMsg escapes control characters in a message string
+// when the logger is configured with console encoding.
+func (l *Logger) sanitizeConsoleMsg(msg string) string {
+ if l != nil && l.consoleEncoding {
+ return consoleControlCharReplacer.Replace(msg)
+ }
+
+ return msg
+}
+
+// logFieldsToZap converts log.Field values to zap.Field values.
+// Sensitive field keys (matched via redaction.IsSensitiveField) are redacted.
+func logFieldsToZap(fields []logpkg.Field) []zap.Field {
+ zapFields := make([]zap.Field, len(fields))
+ for i, f := range fields {
+ if redaction.IsSensitiveField(f.Key) {
+ zapFields[i] = zap.String(f.Key, redactedValue)
+ } else {
+ zapFields[i] = zap.Any(f.Key, f.Value)
+ }
+ }
+
+ return zapFields
+}
diff --git a/zap/zap_test.go b/zap/zap_test.go
new file mode 100644
index 0000000..7e966f7
--- /dev/null
+++ b/zap/zap_test.go
@@ -0,0 +1,618 @@
+//go:build unit
+
+package zap
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ logpkg "github.com/LerianStudio/lib-observability/log"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel/trace"
+ "go.uber.org/zap"
+ "go.uber.org/zap/zapcore"
+ "go.uber.org/zap/zaptest/observer"
+)
+
+func newObservedLogger(level zapcore.Level) (*Logger, *observer.ObservedLogs) {
+ core, observed := observer.New(level)
+
+ return &Logger{logger: zap.New(core)}, observed
+}
+
+// newBufferedLogger creates a Logger that writes JSON to a buffer for output
+// inspection (e.g., verifying CWE-117 sanitization in serialized output).
+func newBufferedLogger(level zapcore.Level) (*Logger, *strings.Builder) {
+ buf := &strings.Builder{}
+ ws := zapcore.AddSync(buf)
+
+ encoderCfg := zap.NewProductionEncoderConfig()
+ encoderCfg.TimeKey = "" // omit timestamp for deterministic test output
+ core := zapcore.NewCore(
+ zapcore.NewJSONEncoder(encoderCfg),
+ ws,
+ level,
+ )
+
+ return &Logger{logger: zap.New(core)}, buf
+}
+
+func TestLoggerNilReceiverFallsBackToNop(t *testing.T) {
+ var nilLogger *Logger
+
+ assert.NotPanics(t, func() {
+ nilLogger.Info("message")
+ })
+}
+
+func TestLoggerNilUnderlyingFallsBackToNop(t *testing.T) {
+ logger := &Logger{}
+
+ assert.NotPanics(t, func() {
+ logger.Info("message")
+ })
+}
+
+func TestStructuredLoggingMethods(t *testing.T) {
+ logger, observed := newObservedLogger(zapcore.DebugLevel)
+
+ logger.Debug("debug message")
+ logger.Info("info message", String("request_id", "req-1"))
+ logger.Warn("warn message")
+ logger.Error("error message", ErrorField(errors.New("boom")))
+
+ entries := observed.All()
+ require.Len(t, entries, 4)
+
+ assert.Equal(t, zapcore.DebugLevel, entries[0].Level)
+ assert.Equal(t, "debug message", entries[0].Message)
+
+ assert.Equal(t, zapcore.InfoLevel, entries[1].Level)
+ assert.Equal(t, "info message", entries[1].Message)
+ assert.Equal(t, "req-1", entries[1].ContextMap()["request_id"])
+
+ assert.Equal(t, zapcore.WarnLevel, entries[2].Level)
+ assert.Equal(t, "warn message", entries[2].Message)
+
+ assert.Equal(t, zapcore.ErrorLevel, entries[3].Level)
+ assert.Equal(t, "error message", entries[3].Message)
+}
+
+func TestWithZapFieldsAddsFieldsWithoutMutatingParent(t *testing.T) {
+ logger, observed := newObservedLogger(zapcore.DebugLevel)
+ child := logger.WithZapFields(String("tenant_id", "t-1"))
+
+ logger.Info("parent")
+ child.Info("child")
+
+ entries := observed.All()
+ require.Len(t, entries, 2)
+
+ _, parentHasTenant := entries[0].ContextMap()["tenant_id"]
+ assert.False(t, parentHasTenant)
+ assert.Equal(t, "t-1", entries[1].ContextMap()["tenant_id"])
+}
+
+func TestSyncReturnsNoErrorForHealthyLogger(t *testing.T) {
+ logger, _ := newObservedLogger(zapcore.DebugLevel)
+
+ require.NoError(t, logger.Sync(context.Background()))
+}
+
+// errorSink is a zapcore.WriteSyncer that always returns an error on Sync.
+type errorSink struct{}
+
+func (e errorSink) Write(p []byte) (int, error) { return len(p), nil }
+func (e errorSink) Sync() error { return errors.New("simulated sync failure") }
+
+// panicSink is a zapcore.WriteSyncer that panics on Sync, for testing
+// the panic-recovery branch in Logger.Sync.
+type panicSink struct{}
+
+func (p panicSink) Write(b []byte) (int, error) { return len(b), nil }
+func (p panicSink) Sync() error { panic("boom from sync") }
+
+func TestSyncReturnsErrorFromFailingSink(t *testing.T) {
+ core := zapcore.NewCore(
+ zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
+ errorSink{},
+ zapcore.DebugLevel,
+ )
+ logger := &Logger{logger: zap.New(core)}
+
+ err := logger.Sync(context.Background())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "simulated sync failure")
+}
+
+func TestSyncRecoversPanicFromSink(t *testing.T) {
+ core := zapcore.NewCore(
+ zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
+ panicSink{},
+ zapcore.DebugLevel,
+ )
+ logger := &Logger{logger: zap.New(core)}
+
+ err := logger.Sync(context.Background())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "panic during logger sync")
+}
+
+func TestFieldHelpers(t *testing.T) {
+ logger, observed := newObservedLogger(zapcore.DebugLevel)
+ logger.Info(
+ "helpers",
+ String("s", "value"),
+ Int("i", 42),
+ Bool("b", true),
+ Duration("d", 2*time.Second),
+ )
+
+ entries := observed.All()
+ require.Len(t, entries, 1)
+ ctx := entries[0].ContextMap()
+
+ assert.Equal(t, "value", ctx["s"])
+ assert.Equal(t, int64(42), ctx["i"])
+ assert.Equal(t, true, ctx["b"])
+ assert.Equal(t, 2*time.Second, ctx["d"])
+}
+
+// ===========================================================================
+// CWE-117: Log Injection Prevention for Zap Adapter
+// ===========================================================================
+
+func TestCWE117_ZapMessageNewlineInjection(t *testing.T) {
+ tests := []struct {
+ name string
+ message string
+ }{
+ {
+ name: "LF in message",
+ message: "legitimate\n{\"level\":\"error\",\"msg\":\"forged entry\"}",
+ },
+ {
+ name: "CR in message",
+ message: "legitimate\r{\"level\":\"error\",\"msg\":\"forged entry\"}",
+ },
+ {
+ name: "CRLF in message",
+ message: "legitimate\r\n{\"level\":\"error\",\"msg\":\"forged entry\"}",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ logger, buf := newBufferedLogger(zapcore.DebugLevel)
+ logger.Info(tt.message)
+ require.NoError(t, logger.Sync(context.Background()))
+
+ out := buf.String()
+ lines := strings.Split(strings.TrimSpace(out), "\n")
+ assert.Len(t, lines, 1,
+ "CWE-117: zap JSON output must be a single line, got %d lines:\n%s", len(lines), out)
+
+ assert.NotContains(t, out, "forged entry\"}",
+ "forged JSON entry must not appear as a separate parseable line")
+ })
+ }
+}
+
+func TestCWE117_ZapFieldValueInjection(t *testing.T) {
+ logger, buf := newBufferedLogger(zapcore.DebugLevel)
+
+ maliciousValue := "user123\n{\"level\":\"error\",\"msg\":\"ADMIN ACCESS GRANTED\"}"
+ logger.Info("login", String("user_id", maliciousValue))
+ require.NoError(t, logger.Sync(context.Background()))
+
+ out := buf.String()
+ lines := strings.Split(strings.TrimSpace(out), "\n")
+ assert.Len(t, lines, 1,
+ "CWE-117: field value injection must not create extra JSON lines")
+}
+
+func TestCWE117_ZapFieldNameInjection(t *testing.T) {
+ logger, buf := newBufferedLogger(zapcore.DebugLevel)
+
+ logger.Info("event", zap.String("key\ninjected", "value"))
+ require.NoError(t, logger.Sync(context.Background()))
+
+ out := buf.String()
+ lines := strings.Split(strings.TrimSpace(out), "\n")
+ assert.Len(t, lines, 1,
+ "CWE-117: field name injection must not create extra JSON lines")
+}
+
+func TestCWE117_ZapNullByteInMessage(t *testing.T) {
+ logger, buf := newBufferedLogger(zapcore.DebugLevel)
+ logger.Info("before\x00after")
+ require.NoError(t, logger.Sync(context.Background()))
+
+ out := buf.String()
+ lines := strings.Split(strings.TrimSpace(out), "\n")
+ assert.Len(t, lines, 1, "null byte must not split log output")
+}
+
+func TestCWE117_ZapANSIEscapeInMessage(t *testing.T) {
+ logger, buf := newBufferedLogger(zapcore.DebugLevel)
+ logger.Info("normal \x1b[31mRED\x1b[0m normal")
+ require.NoError(t, logger.Sync(context.Background()))
+
+ out := buf.String()
+ lines := strings.Split(strings.TrimSpace(out), "\n")
+ assert.Len(t, lines, 1, "ANSI escape must not split log output")
+}
+
+func TestCWE117_ZapTabInMessage(t *testing.T) {
+ logger, buf := newBufferedLogger(zapcore.DebugLevel)
+ logger.Info("col1\tcol2\tcol3")
+ require.NoError(t, logger.Sync(context.Background()))
+
+ out := buf.String()
+ lines := strings.Split(strings.TrimSpace(out), "\n")
+ assert.Len(t, lines, 1, "tabs must not split log output")
+ assert.Contains(t, out, "col1")
+ assert.Contains(t, out, "col2")
+}
+
+func TestCWE117_ZapWithPreservesSanitization(t *testing.T) {
+ logger, buf := newBufferedLogger(zapcore.DebugLevel)
+ child := logger.WithZapFields(String("session", "sess\n{\"forged\":true}"))
+ child.Info("child message")
+ require.NoError(t, logger.Sync(context.Background()))
+
+ out := buf.String()
+ lines := strings.Split(strings.TrimSpace(out), "\n")
+ assert.Len(t, lines, 1,
+ "CWE-117: With() must not allow field injection to split lines")
+}
+
+func TestCWE117_ZapMultipleVectorsSimultaneously(t *testing.T) {
+ logger, buf := newBufferedLogger(zapcore.DebugLevel)
+
+ msg := "event\n{\"level\":\"error\",\"msg\":\"forged\"}\ttab\r\nmore"
+ logger.Info(msg,
+ zap.String("user\nfake", "val\nfake"),
+ zap.String("safe_key", "safe_val"))
+ require.NoError(t, logger.Sync(context.Background()))
+
+ out := buf.String()
+ lines := strings.Split(strings.TrimSpace(out), "\n")
+ assert.Len(t, lines, 1,
+ "CWE-117: combined attack vectors must not create multiple JSON lines")
+}
+
+// ===========================================================================
+// Zap Level Filtering Tests
+// ===========================================================================
+
+func TestZapLevelFiltering(t *testing.T) {
+ t.Run("info level suppresses debug", func(t *testing.T) {
+ logger, observed := newObservedLogger(zapcore.InfoLevel)
+ logger.Debug("should be suppressed")
+ logger.Info("should appear")
+
+ entries := observed.All()
+ require.Len(t, entries, 1)
+ assert.Equal(t, "should appear", entries[0].Message)
+ })
+
+ t.Run("error level suppresses warn and below", func(t *testing.T) {
+ logger, observed := newObservedLogger(zapcore.ErrorLevel)
+ logger.Debug("suppressed")
+ logger.Info("suppressed")
+ logger.Warn("suppressed")
+ logger.Error("visible")
+
+ entries := observed.All()
+ require.Len(t, entries, 1)
+ assert.Equal(t, "visible", entries[0].Message)
+ })
+}
+
+func TestZapRawReturnsUnderlyingLogger(t *testing.T) {
+ logger, _ := newObservedLogger(zapcore.DebugLevel)
+ raw := logger.Raw()
+ assert.NotNil(t, raw)
+}
+
+func TestZapRawOnNilReturnsNop(t *testing.T) {
+ var logger *Logger
+ raw := logger.Raw()
+ assert.NotNil(t, raw, "Raw() on nil logger should return nop, not nil")
+}
+
+func TestZapErrorFieldHelper(t *testing.T) {
+ logger, observed := newObservedLogger(zapcore.DebugLevel)
+ testErr := errors.New("test error")
+ logger.Error("failed", ErrorField(testErr))
+
+ entries := observed.All()
+ require.Len(t, entries, 1)
+ assert.Equal(t, "test error", entries[0].ContextMap()["error"].(string))
+}
+
+func TestZapAnyFieldHelper(t *testing.T) {
+ logger, observed := newObservedLogger(zapcore.DebugLevel)
+ logger.Info("test",
+ Any("slice", []string{"a", "b"}),
+ Any("map", map[string]int{"x": 1}))
+
+ entries := observed.All()
+ require.Len(t, entries, 1)
+ ctx := entries[0].ContextMap()
+ assert.NotNil(t, ctx["slice"])
+ assert.NotNil(t, ctx["map"])
+}
+
+// ===========================================================================
+// log.Logger interface coverage
+// ===========================================================================
+
+func TestLogAllLevels(t *testing.T) {
+ t.Parallel()
+
+ logger, observed := newObservedLogger(zapcore.DebugLevel)
+
+ logger.Log(context.Background(), logpkg.LevelDebug, "debug via Log")
+ logger.Log(context.Background(), logpkg.LevelInfo, "info via Log")
+ logger.Log(context.Background(), logpkg.LevelWarn, "warn via Log")
+ logger.Log(context.Background(), logpkg.LevelError, "error via Log")
+
+ entries := observed.All()
+ require.Len(t, entries, 4)
+
+ assert.Equal(t, zapcore.DebugLevel, entries[0].Level)
+ assert.Equal(t, zapcore.InfoLevel, entries[1].Level)
+ assert.Equal(t, zapcore.WarnLevel, entries[2].Level)
+ assert.Equal(t, zapcore.ErrorLevel, entries[3].Level)
+}
+
+func TestLogDefaultLevel(t *testing.T) {
+ t.Parallel()
+
+ logger, observed := newObservedLogger(zapcore.DebugLevel)
+
+ logger.Log(context.Background(), logpkg.Level(99), "default level")
+
+ entries := observed.All()
+ require.Len(t, entries, 1)
+ assert.Equal(t, zapcore.InfoLevel, entries[0].Level, "unknown level should default to Info")
+}
+
+func TestLogWithNilContext(t *testing.T) {
+ t.Parallel()
+
+ logger, observed := newObservedLogger(zapcore.DebugLevel)
+
+ assert.NotPanics(t, func() {
+ //nolint:staticcheck // intentionally passing nil context
+ logger.Log(nil, logpkg.LevelInfo, "nil ctx message")
+ })
+
+ entries := observed.All()
+ require.Len(t, entries, 1)
+ assert.Equal(t, "nil ctx message", entries[0].Message)
+ _, hasTrace := entries[0].ContextMap()["trace_id"]
+ assert.False(t, hasTrace)
+}
+
+func TestLogWithOTelSpanInjectsTraceFields(t *testing.T) {
+ t.Parallel()
+
+ logger, observed := newObservedLogger(zapcore.DebugLevel)
+
+ traceID, _ := trace.TraceIDFromHex("0af7651916cd43dd8448eb211c80319c")
+ spanID, _ := trace.SpanIDFromHex("b7ad6b7169203331")
+ sc := trace.NewSpanContext(trace.SpanContextConfig{
+ TraceID: traceID,
+ SpanID: spanID,
+ TraceFlags: trace.FlagsSampled,
+ })
+ ctx := trace.ContextWithSpanContext(context.Background(), sc)
+
+ logger.Log(ctx, logpkg.LevelInfo, "traced message", logpkg.String("request_id", "req-42"))
+
+ entries := observed.All()
+ require.Len(t, entries, 1)
+
+ cm := entries[0].ContextMap()
+ assert.Equal(t, traceID.String(), cm["trace_id"])
+ assert.Equal(t, spanID.String(), cm["span_id"])
+ assert.Equal(t, "req-42", cm["request_id"])
+}
+
+func TestLogWithInvalidSpanDoesNotInjectTraceFields(t *testing.T) {
+ t.Parallel()
+
+ logger, observed := newObservedLogger(zapcore.DebugLevel)
+
+ logger.Log(context.Background(), logpkg.LevelInfo, "no span")
+
+ entries := observed.All()
+ require.Len(t, entries, 1)
+
+ _, hasTrace := entries[0].ContextMap()["trace_id"]
+ assert.False(t, hasTrace)
+}
+
+func TestWithReturnsChildLogger(t *testing.T) {
+ t.Parallel()
+
+ logger, observed := newObservedLogger(zapcore.DebugLevel)
+
+ child := logger.With(logpkg.String("component", "auth"))
+ child.Log(context.Background(), logpkg.LevelInfo, "child msg")
+
+ logger.Log(context.Background(), logpkg.LevelInfo, "parent msg")
+
+ entries := observed.All()
+ require.Len(t, entries, 2)
+
+ assert.Equal(t, "auth", entries[0].ContextMap()["component"])
+ _, parentHas := entries[1].ContextMap()["component"]
+ assert.False(t, parentHas)
+}
+
+func TestWithGroupNamespacesFields(t *testing.T) {
+ t.Parallel()
+
+ logger, buf := newBufferedLogger(zapcore.DebugLevel)
+
+ grouped := logger.WithGroup("http")
+ grouped.Log(context.Background(), logpkg.LevelInfo, "grouped msg", logpkg.String("method", "GET"))
+ require.NoError(t, logger.Sync(context.Background()))
+
+ out := buf.String()
+ assert.Contains(t, out, `"http"`)
+ assert.Contains(t, out, `"method"`)
+ assert.Contains(t, out, `"GET"`)
+ assert.Contains(t, out, "grouped msg")
+}
+
+func TestEnabledReportsCorrectly(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ coreLevel zapcore.Level
+ checkLvl logpkg.Level
+ expected bool
+ }{
+ {"debug enabled at debug", zapcore.DebugLevel, logpkg.LevelDebug, true},
+ {"info enabled at debug", zapcore.DebugLevel, logpkg.LevelInfo, true},
+ {"warn enabled at debug", zapcore.DebugLevel, logpkg.LevelWarn, true},
+ {"error enabled at debug", zapcore.DebugLevel, logpkg.LevelError, true},
+ {"debug disabled at info", zapcore.InfoLevel, logpkg.LevelDebug, false},
+ {"info enabled at info", zapcore.InfoLevel, logpkg.LevelInfo, true},
+ {"debug disabled at error", zapcore.ErrorLevel, logpkg.LevelDebug, false},
+ {"info disabled at error", zapcore.ErrorLevel, logpkg.LevelInfo, false},
+ {"warn disabled at error", zapcore.ErrorLevel, logpkg.LevelWarn, false},
+ {"error enabled at error", zapcore.ErrorLevel, logpkg.LevelError, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ logger, _ := newObservedLogger(tt.coreLevel)
+ assert.Equal(t, tt.expected, logger.Enabled(tt.checkLvl))
+ })
+ }
+}
+
+func TestSyncWithCancelledContext(t *testing.T) {
+ t.Parallel()
+
+ logger, _ := newObservedLogger(zapcore.DebugLevel)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ err := logger.Sync(ctx)
+ require.Error(t, err)
+ assert.ErrorIs(t, err, context.Canceled)
+}
+
+func TestLevelReturnsAtomicLevel(t *testing.T) {
+ t.Parallel()
+
+ al := zap.NewAtomicLevelAt(zapcore.WarnLevel)
+ logger := &Logger{
+ logger: zap.NewNop(),
+ atomicLevel: al,
+ }
+
+ assert.Equal(t, zapcore.WarnLevel, logger.Level().Level())
+}
+
+func TestLevelOnNilReceiverReturnsDefault(t *testing.T) {
+ t.Parallel()
+
+ var logger *Logger
+ level := logger.Level()
+ assert.Equal(t, zapcore.InfoLevel, level.Level(),
+ "nil receiver should return default AtomicLevel (info)")
+}
+
+func TestWithGroupEmptyNameReturnsReceiver(t *testing.T) {
+ t.Parallel()
+
+ logger, _ := newObservedLogger(zapcore.DebugLevel)
+
+ same := logger.WithGroup("")
+ assert.Equal(t, logger, same, "WithGroup(\"\") should return the same logger")
+}
+
+func TestSensitiveFieldRedaction(t *testing.T) {
+ t.Parallel()
+
+ logger, observed := newObservedLogger(zapcore.DebugLevel)
+ logger.Log(context.Background(), logpkg.LevelInfo, "login",
+ logpkg.String("password", "super_secret"),
+ logpkg.String("api_key", "key-12345"),
+ logpkg.String("user_id", "u-42"),
+ )
+
+ entries := observed.All()
+ require.Len(t, entries, 1)
+ ctx := entries[0].ContextMap()
+
+ assert.Equal(t, "[REDACTED]", ctx["password"],
+ "password field must be redacted")
+ assert.Equal(t, "[REDACTED]", ctx["api_key"],
+ "api_key field must be redacted")
+ assert.Equal(t, "u-42", ctx["user_id"],
+ "non-sensitive fields must pass through")
+}
+
+func TestConsoleEncodingSanitizesMessages(t *testing.T) {
+ buf := &strings.Builder{}
+ ws := zapcore.AddSync(buf)
+
+ encoderCfg := zap.NewDevelopmentEncoderConfig()
+ encoderCfg.TimeKey = ""
+ core := zapcore.NewCore(
+ zapcore.NewConsoleEncoder(encoderCfg),
+ ws,
+ zapcore.DebugLevel,
+ )
+
+ logger := &Logger{
+ logger: zap.New(core),
+ consoleEncoding: true,
+ }
+
+ logger.Log(context.Background(), logpkg.LevelInfo, "line1\nline2\rline3")
+ require.NoError(t, logger.Sync(context.Background()))
+
+ out := buf.String()
+ lines := strings.Split(strings.TrimSpace(out), "\n")
+ assert.Len(t, lines, 1,
+ "console output with injection attempt must remain a single line, got: %q", out)
+}
+
+func TestLogLevelToZapConversions(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ input logpkg.Level
+ expected zapcore.Level
+ }{
+ {logpkg.LevelDebug, zapcore.DebugLevel},
+ {logpkg.LevelInfo, zapcore.InfoLevel},
+ {logpkg.LevelWarn, zapcore.WarnLevel},
+ {logpkg.LevelError, zapcore.ErrorLevel},
+ {logpkg.Level(42), zapcore.InfoLevel},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input.String(), func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.expected, logLevelToZap(tt.input))
+ })
+ }
+}