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 @@ + + + + + +
Lerian

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)) + }) + } +}