{"id":50362374,"url":"https://github.com/adrielcodeco/go-tools","last_synced_at":"2026-05-30T02:30:29.202Z","repository":{"id":360563103,"uuid":"1238977720","full_name":"adrielcodeco/go-tools","owner":"adrielcodeco","description":"Fiber + GORM toolbox: request-scoped DB transactions (lazy BEGIN, OnCommit/OnRollback), Kubernetes-aware graceful shutdown with rueidis adapter, and Elastic APM tracing (foldable DB spans, log↔trace correlation, labels). Supports Fiber v2 and v3.","archived":false,"fork":false,"pushed_at":"2026-05-26T23:15:11.000Z","size":277,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-27T01:14:32.402Z","etag":null,"topics":["apm","database","elastic-apm","fiber","fiber-v2","fiber-v3","go","gofiber","golang","gorm","graceful-shutdown","kubernetes","middleware","observability","opentelemetry","outbox-pattern","readiness-probe","redis","rueidis","transaction"],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/adrielcodeco.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":"adrielcodeco"}},"created_at":"2026-05-14T16:31:21.000Z","updated_at":"2026-05-26T23:15:14.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/adrielcodeco/go-tools","commit_stats":null,"previous_names":["adrielcodeco/go-tools"],"tags_count":54,"template":false,"template_full_name":null,"purl":"pkg:github/adrielcodeco/go-tools","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrielcodeco%2Fgo-tools","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrielcodeco%2Fgo-tools/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrielcodeco%2Fgo-tools/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrielcodeco%2Fgo-tools/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/adrielcodeco","download_url":"https://codeload.github.com/adrielcodeco/go-tools/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrielcodeco%2Fgo-tools/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33678270,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-30T02:00:06.278Z","response_time":92,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["apm","database","elastic-apm","fiber","fiber-v2","fiber-v3","go","gofiber","golang","gorm","graceful-shutdown","kubernetes","middleware","observability","opentelemetry","outbox-pattern","readiness-probe","redis","rueidis","transaction"],"created_at":"2026-05-30T02:30:28.459Z","updated_at":"2026-05-30T02:30:29.191Z","avatar_url":"https://github.com/adrielcodeco.png","language":"Go","funding_links":["https://github.com/sponsors/adrielcodeco"],"categories":[],"sub_categories":[],"readme":"# go-tools\n\nA toolbox for production [Fiber](https://github.com/gofiber/fiber) + [GORM](https://gorm.io) services. It ships three complementary primitives that share the same design philosophy (framework-agnostic core + thin Fiber adapters for v2 and v3):\n\n1. **`txctx` / `txctxv3`** — request-scoped database transactions with lazy `BEGIN`, automatic rollback on error/timeout/panic, and commit/rollback callbacks.\n2. **`gsfiber` / `gsfiberv3`** — Kubernetes-aware graceful shutdown for Fiber + GORM + outbound calls, with ordered phases, hooks, liveness/readiness/startup probes, and force-kill ceiling.\n3. **`apmfiber` / `apmfiberv3`** — Elastic APM tracing for Fiber + GORM + outgoing HTTP + Redis: foldable DB spans, log↔trace correlation, transaction labels, error capture, and DB-pool metrics.\n4. **`gsrueidis`** — Rueidis adapter for the graceful-shutdown manager, with timeout-bounded close so a wedged Redis client cannot stall shutdown.\n5. **`gsredis`** — go-redis/v9 adapter for the graceful-shutdown manager, analogous to gsrueidis.\n6. **`httpclient`** — Generics-friendly fasthttp client wrapper with built-in APM tracing, pluggable structured-log hook, configurable retry, and sonic JSON.\n7. **`logcore` + `logfiber` / `logfiberv3`** — Structured zap logger pre-wired for APM (auto-error capture, trace.id correlation), with Fiber incoming middleware and an httpclient outgoing hook that share the same `req`/`res`/`responseTime` schema.\n8. **`gormautobatch`** — GORM plugin that transparently batches Create/Update/Delete operations based on measured P95 latency, reducing round-trips under load with per-op SAVEPOINT isolation.\n9. **`gormcache`** — GORM plugin for query result caching and request deduplication (easer). Ships with ready-made Redis and Rueidis backends (`gsredis.NewRedisCacher`, `gsrueidis.NewRueidisCache`) and optional OTel instrumentation via `apmcore.InstrumentCacher`.\n10. **`setup`** — One-call bootstrap that wires the gscore Manager, Fiber app, GORM, APM, logger, httpclient, gsredis, gsrueidis, and gormcache together in the correct order. Recommended for production services.\n\n**Module:** `github.com/adrielcodeco/go-tools`\n\n| Feature | Fiber v2 | Fiber v3 | Go min |\n|---|---|---|---|\n| Request-scoped transactions | `…/txctx` | `…/txctxv3` | 1.22 / 1.25 |\n| Graceful shutdown | `…/gsfiber` | `…/gsfiberv3` | 1.22 / 1.25 |\n| Elastic APM instrumentation | `…/apmfiber` | `…/apmfiberv3` | 1.22 / 1.25 |\n\nEach trio shares a framework-agnostic engine (`txcore`, `gscore`, `apmcore`) so both Fiber versions have identical semantics.\n\n---\n\n## Table of Contents\n\n- [Quick Setup (`setup`)](#quick-setup-setup)\n- [Module integration map](#module-integration-map)\n  - [Dependency graph](#dependency-graph)\n  - [APM + txctx: span parenting](#apm--txctx-span-parenting)\n  - [APM + gormautobatch: batch spans](#apm--gormautobatch-batch-spans)\n  - [APM + logcore: trace correlation](#apm--logcore-trace-correlation)\n  - [APM + httpclient: exit spans + outgoing logs](#apm--httpclient-exit-spans--outgoing-logs)\n  - [gscore + everything: graceful shutdown registration order](#gscore--everything-graceful-shutdown-registration-order)\n  - [gsredis / gsrueidis: which one to use](#gsredis--gsrueidis-which-one-to-use)\n  - [logcore + gscore: logger lifecycle](#logcore--gscore-logger-lifecycle)\n- [Transactions (`txctx` / `txctxv3`)](#transactions-txctx--txctxv3)\n  - [Features](#features)\n  - [Installation](#installation)\n  - [Public API](#public-api)\n  - [Usage](#usage)\n  - [Commit / Rollback Decision Table](#commit--rollback-decision-table)\n  - [Propagating cancellation to outbound calls](#propagating-cancellation-to-outbound-calls)\n- [Graceful Shutdown (`gsfiber` / `gsfiberv3`)](#graceful-shutdown-gsfiber--gsfiberv3)\n  - [Features](#features-1)\n  - [Installation](#installation-1)\n  - [Public API](#public-api-1)\n  - [Phases](#phases)\n  - [Usage](#usage-1)\n  - [Kubernetes integration](#kubernetes-integration)\n    - [Health probe handlers](#health-probe-handlers)\n    - [Recommended Kubernetes manifest](#recommended-kubernetes-manifest)\n    - [Shutdown sequence on kubectl delete pod](#shutdown-sequence-on-kubectl-delete-pod)\n- [Rueidis graceful shutdown (`gsrueidis`)](#rueidis-gsrueidis)\n- [Redis graceful shutdown (`gsredis`)](#redis-graceful-shutdown-gsredis)\n- [GORM Auto-batch (`gormautobatch`)](#gorm-auto-batch-gormautobatch)\n- [GORM Cache (`gormcache`)](#gorm-cache-gormcache)\n  - [Features](#features-3)\n  - [Installation](#installation-3)\n  - [Quick start](#quick-start)\n  - [Easer (request deduplication)](#easer-request-deduplication)\n  - [Cacher interface](#cacher-interface)\n  - [Ready-made backends](#ready-made-backends)\n  - [Tag-based invalidation](#tag-based-invalidation)\n  - [OTel instrumentation](#otel-instrumentation)\n  - [setup integration](#setup-integration)\n- [Elastic APM (`apmfiber` / `apmfiberv3`)](#elastic-apm-apmfiber--apmfiberv3)\n  - [Packages](#packages)\n  - [Features](#features-2)\n  - [Installation](#installation-2)\n  - [Quick start (Fiber v2)](#quick-start-fiber-v2)\n  - [Manager integration](#manager-integration)\n  - [APM transaction context in txctx](#apm-transaction-context-in-txctx)\n  - [Local stack](#local-stack)\n  - [Pitfall index](#pitfall-index)\n- [HTTP client (`httpclient`)](#http-client-httpclient)\n- [Structured logging (`logcore` / `logfiber` / `logfiberv3`)](#structured-logging-logcore--logfiber--logfiberv3)\n\n---\n\n## Quick Setup (`setup`)\n\nThe `setup` package wires the full standard stack in one call with the correct\nregistration order, eliminating the manual \"register everything in the right\norder\" ceremony. It is the recommended way to bootstrap a production service.\n\n```bash\ngo get github.com/adrielcodeco/go-tools/setup\n```\n\n```go\nfunc main() {\n    ctx := context.Background()\n    db := openGORM()\n    app := fiber.New()\n\n    mgr := gscore.New(gscore.Config{\n        PreStopDelay:   5 * time.Second,\n        DrainTimeout:   25 * time.Second,\n        DBCloseTimeout: 5 * time.Second,\n        ForceKillAfter: 55 * time.Second, // must be \u003c terminationGracePeriodSeconds\n    })\n\n    result, err := setup.New().\n        WithLogger(logcore.Options{Service: \"my-service\", Version: \"1.0.0\"}).\n        WithOTel(ctx).\n        WithGORM(db).\n        WithFiberV2(app).\n        WithHealthProbesV2(setup.HealthProbesConfig{}). // registers /healthz/{live,ready,startup}\n        WithHTTPClientLogging().\n        WithStartupFn(func() error {                    // runs migrations; calls MarkStarted on success\n            return runMigrations(db)\n        }).\n        Build(mgr)\n    if err != nil {\n        log.Fatal(err) // includes startup function errors\n    }\n    defer result.Shutdown(ctx)\n\n    registerRoutes(app)\n\n    // Background workers must derive their context from mgr.RootContext() so\n    // they are cancelled the instant SIGTERM is received, before the drain starts.\n    go pollWorker(mgr.RootContext())\n\n    // ListenAndTrigger starts the server in a goroutine and calls mgr.Trigger()\n    // if Listen fails (e.g. port already in use), so the process never hangs\n    // waiting for a signal that will never arrive.\n    gsfiber.ListenAndTrigger(app, mgr, \":8080\")\n\n    if err := mgr.ListenAndWait(); err != nil {\n        log.Fatal(err)\n    }\n}\n```\n\n`Build` performs all registrations in a fixed, safe order:\n\n1. Register Fiber app with the Manager\n2. `apmcore.SetupOTelSDK` — OTel/APM bootstrap\n3. `logcore.New` + `logcore.SetGlobal` — structured logger\n4. Install the GORM APM plugin (`apmcore.NewGormPlugin`)\n5. `mgr.RegisterDB` — GORM pool close during `PhaseDB`\n6. `txcore.RegisterWithManager` — drain in-flight transactions before DB closes\n7. `apmcore.RegisterDBPoolMetricsWithManager` — deregister pool metric gatherer at shutdown\n8. `autobatch.RegisterWithManager` (if `WithAutobatch`/`WithAutobatchConfig` set)\n9. `db.Use(gormcache plugin)` (if `WithGORMCache`/`WithGORMCacheConfig` set; `Cacher` auto-wrapped with OTel if `WithOTel` set)\n10. go-redis closers + optional OTel instrumentation (if `WithRedis`)\n11. rueidis closers + optional OTel wrapping (if `WithRueidis`)\n12. `apmcore.RegisterWithManager` — flush OTel spans/metrics at `PhasePostDB`\n13. `logcore.RegisterGlobalWithManager` — flush zap buffers last\n\n### Builder API\n\n```go\nsetup.New().\n    WithLogger(logcore.Options{...}).          // create + set global logger\n    WithOTel(ctx).                             // call SetupOTelSDK\n    WithGORM(db).                              // register GORM plugin, DB close, txcore\n    WithAutobatchConfig(autobatch.Config{...}). // create + register autobatch plugin\n    WithAutobatch(existingPlugin).             // register a pre-built autobatch plugin\n    WithGORMCacheConfig(caches.Config{...}).   // create + register gormcache plugin (OTel auto-wired)\n    WithGORMCache(existingPlugin).             // register a pre-built gormcache plugin\n    WithRedis(client, \"redis-cache\").          // go-redis closer + OTel instrumentation\n    WithRueidis(client, \"redis-pubsub\").       // rueidis closer + OTel wrapping\n    WithFiberV2(app).                          // register Fiber v2 app for drain\n    WithHealthProbesV2(setup.HealthProbesConfig{}). // register /healthz/{live,ready,startup}\n    WithFiberV3(app).                          // register Fiber v3 app for drain\n    WithHealthProbesV3(setup.HealthProbesConfig{}). // register /healthz/{live,ready,startup}\n    WithHTTPClientLogging().                   // install logcore hook on httpclient\n    WithStartupFn(func() error {               // run boot logic; calls MarkStarted on success\n        return runMigrations(db)\n    }).\n    Build(mgr)                                 // wire everything; returns *Result, error\n```\n\n`WithHealthProbesV2` / `WithHealthProbesV3` must be called after the\ncorresponding `WithFiberV2` / `WithFiberV3`. Calling `WithHealthProbesV2`\nwithout a prior `WithFiberV2` causes `Build` to return an error.\n`HealthProbesConfig` may be zero-valued to use the defaults\n(`/healthz/live`, `/healthz/ready`, `/healthz/startup`), or you can\noverride individual paths:\n\n```go\nWithHealthProbesV2(setup.HealthProbesConfig{\n    LivenessPath:  \"/live\",\n    ReadinessPath: \"/ready\",\n    StartupPath:   \"/startup\",\n})\n```\n\n`Result.Logger` is the `*logcore.Logger` created by `WithLogger` (nil if not\nset). `Result.Shutdown` is the OTel shutdown function from `SetupOTelSDK`.\n\nWhen `WithOTel` and `WithAutobatchConfig` are both set, `Build` automatically\ninjects `cfg.SpanEmitter = apmcore.BatchSpanEmitter()` so batched writes appear\nas APM spans without any extra configuration.\n\n---\n\n## Module integration map\n\nEvery module in this toolbox is independently importable, but they are designed\nto compose. This section shows how they connect and what order matters.\n\n### Dependency graph\n\n```\n                        ┌─────────────────────────────────────────────────┐\n                        │                   setup                         │\n                        │  (wires everything below in the correct order)  │\n                        └───────────────────┬─────────────────────────────┘\n                                            │\n     ┌──────────────┬──────────────────────┼──────────────────┬──────────────────┐\n     ▼              ▼                      ▼                  ▼                  ▼\n gsfiber/       apmfiber/             logfiber/           txctx/            gsredis/\ngsfiberv3     apmfiberv3             logfiberv3          txctxv3           gsrueidis\n     │              │                      │                  │                  │\n     ▼              ▼                      ▼                  ▼                  ▼\n  gscore         apmcore               logcore            txcore           gormcache\n     │              │                      │                  │\n     └──────────────┴──────────────────────┴──────────────────┘\n                                    │\n                              go-tools (root)\n                         gscore.CloserRegistrar,\n                          txctx.ContextExtractor, …\n```\n\n### Connection points between modules\n\n#### APM + txctx: span parenting\n\n`apmfiber.Middleware()` attaches the APM transaction to the underlying\n`*fasthttp.RequestCtx`. `txctx.Middleware()` derives its internal context from\n`context.Background()` by default, which breaks span nesting — DB spans created\ninside handlers end up as root spans in Kibana instead of children of the\nrequest transaction.\n\nFix: pass `apmfiber.TxContextExtractor()` to `txctx.Middleware` so it inherits\nthe request context that already carries the APM transaction:\n\n```go\napp.Use(apmfiber.Middleware())   // must be first\napp.Use(txctx.Middleware(db, txctx.Config{...}, apmfiber.TxContextExtractor()))\n```\n\nThis is wired automatically when using the `setup` package.\n\n#### APM + gormautobatch: batch spans\n\n`gormautobatch` can emit an APM span per flush via its `Config.SpanEmitter`\nfield. `apmcore.BatchSpanEmitter()` returns the right function:\n\n```go\nplugin := autobatch.New(autobatch.Config{\n    SpanEmitter: apmcore.BatchSpanEmitter(),\n})\n```\n\nWhen using `setup.New().WithOTel(ctx).WithAutobatchConfig(cfg)`, this wiring\nis automatic — `Build` injects `BatchSpanEmitter` before registering the\nplugin.\n\n#### APM + logcore: trace correlation\n\n`logcore.New` wraps the zap core with `apmzap.Core` by default. Any\n`logger.Error(...)` call is automatically emitted as an APM error event, and\n`logcore.LogCtx(ctx)` appends `trace.id` / `transaction.id` fields to every\nlog line. No extra setup needed — the correlation is active as long as\n`apmcore.SetupOTelSDK` was called before `logcore.New`.\n\n#### APM + httpclient: exit spans + outgoing logs\n\n`httpclient` produces an APM exit span for every call via\n`apmcore.TraceFastHTTPCall` internally. It also propagates the active\ntransaction's `traceparent` header so downstream services appear as children in\nthe APM trace waterfall.\n\nTo add structured logging on top, install the logcore hook:\n\n```go\nhttpclient.SetHook(logcore.HTTPClientHook())\n```\n\nBoth concerns are enabled by `setup.New().WithOTel(ctx).WithHTTPClientLogging()`.\n\n#### gscore + everything: graceful shutdown registration order\n\nThe shutdown sequence is ordered. Registering in the wrong phase causes\nresources to be torn down before dependents have finished:\n\n| Phase | What to register |\n|---|---|\n| `PhasePreStop` | In-memory queue flushes, worker signals |\n| `PhaseDrain` | Fiber app drain (automatic via `gsfiber.RegisterApp`) |\n| `PhasePostDrain` | `txcore.RegisterWithManager` — wait for in-flight transactions |\n| `PhaseDB` | `mgr.RegisterDB(db)` — close GORM pool |\n| `PhasePostDB` | `gsredis`, `gsrueidis`, `apmcore.RegisterWithManager`, `logcore.RegisterGlobalWithManager` |\n\n`setup.Build` follows this order exactly. If you wire manually, register in the\norder shown above — particularly, do **not** close Redis before transactions\nfinish, and do **not** flush the logger before OTel spans are exported.\n\n#### APM + gormcache: cache spans\n\n`apmcore.InstrumentCacher(inner)` wraps any `caches.Cacher` with OTel spans so\ncache hits, misses, stores, and invalidations appear in APM traces alongside DB\nspans. When using `setup.New().WithOTel(ctx).WithGORMCacheConfig(cfg)`, this\nwrapping is automatic — `Build` injects `InstrumentCacher` before registering\nthe plugin.\n\n#### gsredis / gsrueidis: which one to use\n\nUse `gsredis` for `go-redis/v9` clients and `gsrueidis` for `rueidis` clients.\nBoth register a timeout-bounded closer at `PhasePostDB`. They are independent —\na service can register both if it uses both clients.\n\nFor APM tracing:\n- `go-redis/v9`: call `apmcore.InstrumentRedis(client)` after `SetupOTelSDK`.\n- `rueidis`: build the client via `rueidisotel.NewClient` (preferred, adds pool\n  metrics) or wrap an existing one with `apmcore.InstrumentRueidis(client)`.\n\n`gsredis.InstrumentAndRegister` and `gsrueidis` combine the OTel instrumentation\nand shutdown registration in one call.\n\n#### logcore + gscore: logger lifecycle\n\nThe zap logger buffers internally. At shutdown, `logcore.RegisterGlobalWithManager`\nregisters a `PhasePostDB` closer that calls `logger.Sync()` — ensuring buffered\nlog lines are flushed after OTel spans (which may themselves log) are exported.\nThe ordering matters: register `apmcore` before `logcore`.\n\n---\n\n## Transactions (`txctx` / `txctxv3`)\n\n---\n\n## Features\n\n- **Lazy transactions** — a DB transaction is only opened when the first write (`Create`/`Update`/`Delete`) occurs in a request. Pure read requests never touch a transaction.\n- **Timeout-triggered rollback** — each request gets a configurable context timeout. If it expires before the handler finishes, the transaction is rolled back automatically.\n- **`Outside(c)`** — returns a `*gorm.DB` connected to `context.Background()`, completely outside the request transaction. Writes via `Outside` persist even if the main transaction rolls back.\n- **`OnRollback(c, fn)`** — registers a compensating callback that runs if the transaction rolls back (timeout, error, or panic). Runs with a fresh context (`CompensationCtx` duration) because the request context is already cancelled.\n- **`OnCommit(c, fn)`** — registers a callback that runs only after a successful commit. Useful for the outbox pattern (publish events only after the DB write is confirmed).\n- **Panic recovery** — the middleware recovers panics, rolls back the transaction, runs `OnRollback` callbacks, then re-panics so Fiber's `ErrorHandler` can still handle it.\n- **Context propagation** — all public functions have both `*fiber.Ctx` and `context.Context` variants, so repository and service layers stay framework-agnostic.\n\n---\n\n## Installation\n\nFor Fiber v2:\n```bash\ngo get github.com/adrielcodeco/go-tools/txctx\n```\n\nFor Fiber v3:\n```bash\ngo get github.com/adrielcodeco/go-tools/txctxv3\n```\n\nThe v3 adapter has the same surface as v2; just swap `txctx` → `txctxv3` and replace `*fiber.Ctx` with `fiber.Ctx` in your handler signatures.\n\n---\n\n## Public API\n\n```go\n// Middleware\ntxctx.Middleware(db *gorm.DB, cfg txctx.Config) fiber.Handler\n\n// Config\ntype Config struct {\n    Timeout         time.Duration   // request deadline (default: 30s)\n    LazyTx          *bool           // open tx only on first write (default: true; use txctx.BoolPtr to set)\n    CompensationCtx time.Duration   // timeout for OnRollback callbacks (default: 5s)\n    OnCallbackError func(error)     // optional sink for errors from OnCommit/OnRollback callbacks and from rollback/commit\n}\n\n// DB access\ntxctx.DB(c *fiber.Ctx) *gorm.DB\ntxctx.DBFromCtx(ctx context.Context) *gorm.DB\n\n// Outside-tx access\ntxctx.Outside(c *fiber.Ctx) *gorm.DB\ntxctx.OutsideCtx(ctx context.Context) *gorm.DB\n\n// Callbacks\ntxctx.OnRollback(c *fiber.Ctx, fn func(*gorm.DB) error)\ntxctx.OnRollbackCtx(ctx context.Context, fn func(*gorm.DB) error)\ntxctx.OnCommit(c *fiber.Ctx, fn func(*gorm.DB) error)\ntxctx.OnCommitCtx(ctx context.Context, fn func(*gorm.DB) error)\n```\n\n---\n\n## Usage\n\n### 1. Setup\n\nRegister the middleware once, globally or on a route group:\n\n```go\napp.Use(txctx.Middleware(db, txctx.Config{\n    Timeout:         5 * time.Second,\n    LazyTx:          txctx.BoolPtr(true),\n    CompensationCtx: 3 * time.Second,\n}))\n```\n\n- `Timeout` — maximum duration allowed for a single request before the context is cancelled and any open transaction is rolled back.\n- `LazyTx` — when `true`, `BEGIN` is deferred until the first write operation. Read-only requests skip transactions entirely.\n- `CompensationCtx` — timeout granted to `OnRollback` callbacks. Because the original request context is already cancelled at rollback time, each callback receives a fresh context with this duration.\n\n---\n\n### 2. Read-only handler\n\nWhen `LazyTx` is `true` and no write happens, `DB(c)` returns a plain `*gorm.DB` without ever opening a transaction.\n\n```go\nfunc getUser(c *fiber.Ctx) error {\n    var u User\n    if err := txctx.DB(c).First(\u0026u, c.Params(\"id\")).Error; err != nil {\n        return err\n    }\n    return c.JSON(u)\n}\n```\n\n---\n\n### 3. Simple write (lazy tx)\n\nThe first call to a write operation (`Create`, `Save`, `Update`, `Delete`) transparently triggers `BEGIN`.\n\n```go\nfunc createUser(c *fiber.Ctx) error {\n    var u User\n    c.BodyParser(\u0026u)\n    // First write: middleware transparently opens BEGIN here\n    if err := txctx.DB(c).Create(\u0026u).Error; err != nil {\n        return err\n    }\n    return c.JSON(u) // handler returns nil → COMMIT\n}\n```\n\n---\n\n### 4. Multiple writes in the same transaction\n\nAll calls to `DB(c)` within the same request share the same underlying transaction.\n\n```go\nfunc createOrder(c *fiber.Ctx) error {\n    db := txctx.DB(c)\n    user := User{Email: \"a@b.com\"}\n    db.Create(\u0026user)                                        // opens tx\n    db.Create(\u0026Order{UserID: user.ID, Total: 100})          // same tx\n    db.Model(\u0026user).Update(\"Name\", \"updated\")               // same tx\n    return c.JSON(user)                                     // COMMIT — all three writes atomic\n}\n```\n\n---\n\n### 5. `Outside` — write that survives rollback\n\n`Outside(c)` returns a `*gorm.DB` backed by `context.Background()`, completely independent of the request transaction. Writes via `Outside` are committed immediately and are not affected by a subsequent rollback of the main transaction.\n\n```go\nfunc signupWithAudit(c *fiber.Ctx) error {\n    var u User\n    c.BodyParser(\u0026u)\n\n    // Persists regardless of what happens to the main tx\n    txctx.Outside(c).Create(\u0026AuditLog{Action: \"signup_attempt\", Payload: u.Email})\n\n    if err := txctx.DB(c).Create(\u0026u).Error; err != nil {\n        return err // rollback of User, but AuditLog stays\n    }\n    return c.JSON(u)\n}\n```\n\n---\n\n### 6. `OnRollback` — compensating transaction\n\n`OnRollback` registers a function that runs only if the transaction is rolled back (due to a handler error, timeout, or panic). The callback receives a `*gorm.DB` with a fresh context whose deadline is `CompensationCtx`.\n\n```go\nfunc paymentHandler(c *fiber.Ctx) error {\n    var u User\n    c.BodyParser(\u0026u)\n    txctx.DB(c).Create(\u0026u)\n\n    txctx.OnRollback(c, func(bg *gorm.DB) error {\n        return bg.Create(\u0026FailedSignup{Email: u.Email, Error: \"rolled back\"}).Error\n    })\n\n    if err := chargeExternal(c.UserContext(), u.ID); err != nil {\n        return err // triggers rollback → OnRollback callback fires\n    }\n    return c.JSON(u)\n}\n```\n\n---\n\n### 7. `OnCommit` — outbox / post-commit event\n\n`OnCommit` registers a function that runs only after a successful commit. This is the recommended pattern for publishing domain events (outbox pattern): the event is only dispatched once the DB write is durably confirmed.\n\n```go\nfunc createOrder(c *fiber.Ctx) error {\n    var o Order\n    c.BodyParser(\u0026o)\n    txctx.DB(c).Create(\u0026o)\n\n    txctx.OnCommit(c, func(bg *gorm.DB) error {\n        return publishEvent(\"order.created\", o.ID)\n    })\n    return c.JSON(o)\n}\n```\n\n---\n\n### 8. Handler returns error → rollback\n\nAny non-nil error returned by the handler causes the middleware to roll back the active transaction before passing the error to Fiber's error handler.\n\n```go\nfunc manualRollback(c *fiber.Ctx) error {\n    var u User\n    txctx.DB(c).Create(\u0026u)\n    if u.Email == \"\" {\n        return errors.New(\"email required\") // rollback triggered\n    }\n    return c.JSON(u)\n}\n```\n\n---\n\n### 9. Panic → rollback + re-panic\n\nThe middleware recovers from panics, rolls back the transaction (running any registered `OnRollback` callbacks), and then re-panics so that Fiber's `ErrorHandler` or `RecoverHandler` can process it normally.\n\n```go\nfunc panicHandler(c *fiber.Ctx) error {\n    txctx.DB(c).Create(\u0026User{Email: \"boom\"})\n    txctx.OnRollback(c, func(bg *gorm.DB) error {\n        return bg.Create(\u0026AuditLog{Action: \"panicked\"}).Error\n    })\n    panic(\"something went very wrong\") // middleware: recover → rollback → re-panic\n}\n```\n\n---\n\n### 10. Layered architecture\n\nThe `*Ctx` variants (`DBFromCtx`, `OutsideCtx`, `OnRollbackCtx`, `OnCommitCtx`) accept a `context.Context` instead of a `*fiber.Ctx`. This allows repository and service layers to remain completely framework-agnostic while still participating in the request-scoped transaction.\n\n```go\n// handler — Fiber layer\nfunc createUserHandler(c *fiber.Ctx) error {\n    var u User\n    c.BodyParser(\u0026u)\n    if err := userService.Create(c.UserContext(), \u0026u); err != nil {\n        return err\n    }\n    return c.JSON(u)\n}\n\n// service — no Fiber dependency\nfunc (s *UserService) Create(ctx context.Context, u *User) error {\n    if err := s.repo.Insert(ctx, u); err != nil {\n        return err\n    }\n    txctx.OnCommitCtx(ctx, func(db *gorm.DB) error {\n        return s.events.Publish(\"user.created\", u.ID)\n    })\n    return nil\n}\n\n// repository — no Fiber dependency\nfunc (r *UserRepository) Insert(ctx context.Context, u *User) error {\n    return txctx.DBFromCtx(ctx).Create(u).Error\n}\n```\n\n---\n\n## Commit / Rollback Decision Table\n\n| Situation | Result |\n|---|---|\n| Handler returns `nil` | COMMIT → `OnCommit` callbacks run |\n| Handler returns `error` | ROLLBACK → `OnRollback` callbacks run |\n| Request context timeout | ROLLBACK → `OnRollback` callbacks run |\n| Panic in handler | ROLLBACK → `OnRollback` callbacks run → re-panic |\n| `tx.Commit()` itself fails | ROLLBACK → `OnRollback` callbacks run → commit error returned |\n| `OnCommit` callback fails (after successful commit) | Tx stays committed; error surfaced via `OnCallbackError` + returned to Fiber. `OnRollback` does **not** fire. |\n| Write via `Outside` | Always persists, independent of tx. Context cancellation is decoupled but values (request-id, tracing) are preserved. |\n\n### Concurrency notes\n\nThe request-scoped `*gorm.DB` is safe for sequential use within the handler.\nIf you spawn goroutines from the handler, do **not** use `DB(c)` from them\nafter the handler returns — the middleware will commit/rollback as soon as\nthe handler returns, and the underlying `*sql.Tx` becomes invalid. Use\n`Outside(c)` for fire-and-forget work, or wait for the goroutine before\nreturning from the handler.\n\n---\n\n## Propagating cancellation to outbound calls\n\nThe middleware wraps `c.UserContext()` with the configured `Timeout`. **Any\noutbound call (HTTP, gRPC, Redis, message broker, etc.) that receives this\ncontext will be cancelled automatically when the request times out, errors,\nor the client disconnects** — Go's standard libraries already implement this:\n`net/http` aborts the in-flight TCP request, `database/sql` interrupts the\nquery, gRPC closes the stream, and so on.\n\nFor this to work you must **thread the context through every outbound call**.\nThe package can't do this for you — it would require wrapping every client\ntype in the ecosystem. The discipline is:\n\n```go\nfunc chargeExternal(c *fiber.Ctx, userID uint) error {\n    // ✅ Pass the request context — cancels on Fiber timeout/error/panic.\n    req, err := http.NewRequestWithContext(c.UserContext(),\n        http.MethodPost, \"https://payments.example/charge\", body)\n    if err != nil {\n        return err\n    }\n    resp, err := http.DefaultClient.Do(req)\n    // ...\n}\n\nfunc chargeExternalBAD(userID uint) error {\n    // ❌ No context: the call will keep running after the request times out,\n    //    burning a goroutine and a connection until the remote replies.\n    resp, err := http.Post(\"https://payments.example/charge\", \"...\", body)\n    // ...\n}\n```\n\nThe same applies to gRPC (`grpc.Invoke(ctx, ...)`), Redis\n(`rdb.Get(ctx, ...)`), AWS SDK v2 (`client.GetItem(ctx, ...)`), and any other\nclient that accepts a `context.Context` as its first argument.\n\n**Service / repository layers:** use the `*Ctx` variants\n(`DBFromCtx`, `OutsideCtx`, `OnRollbackCtx`, `OnCommitCtx`) so the same\n`context.Context` flows through the whole call chain — DB, HTTP, gRPC, queue\npublishes, etc. — and a single cancellation point unwinds everything.\n\n**When you need to escape cancellation** (e.g. publishing a \"request-failed\"\nevent to a queue from `OnRollback` callbacks), `Outside(c)` already gives you\na context decoupled from the request cancellation while preserving values\nlike request-id and trace headers — use the same pattern for outbound HTTP\nin that scenario:\n\n```go\ntxctx.OnRollback(c, func(_ *gorm.DB) error {\n    // Need a fresh ctx because c.UserContext() is already cancelled here.\n    ctx, cancel := context.WithTimeout(\n        context.WithoutCancel(c.UserContext()), 3*time.Second)\n    defer cancel()\n    req, _ := http.NewRequestWithContext(ctx, http.MethodPost, alertURL, body)\n    _, _ = http.DefaultClient.Do(req)\n    return nil\n})\n```\n\n---\n\n## Graceful Shutdown (`gsfiber` / `gsfiberv3`)\n\nA coordinator for the full shutdown sequence of a Fiber + GORM service:\n**drain in-flight HTTP requests**, **cancel outbound calls**, **flush\napplication state**, **close the database pool**, all bounded by per-phase\nand global timeouts. Designed around the Kubernetes pod lifecycle.\n\n### Features\n\n- **Phased sequence** — `PreStop → Drain → PostDrain → DB → PostDB`, so each\n  resource is cleaned up at the right moment (e.g. flush outbox *before*\n  closing the DB; close Redis *after*).\n- **Ordered hooks** — each phase runs registered hooks sorted by `Priority`;\n  a failing hook is logged but does not stop the sequence.\n- **`RootContext()`** — a `context.Context` that is cancelled the moment\n  shutdown begins. Derive outbound HTTP/gRPC/queue calls from it and they\n  abort cleanly on SIGTERM.\n- **Readiness flip** — `IsReady()` (and the provided `ReadinessHandler`)\n  returns `200` while serving and `503` once shutdown begins, so kube-proxy\n  can remove the pod from service endpoints before any request is dropped.\n- **Configurable timeouts** — independent `PreStopDelay`, `DrainTimeout`,\n  `HookTimeout`, `DBCloseTimeout`, plus a global `ForceKillAfter` that\n  `os.Exit(1)`s if the whole sequence overshoots\n  `terminationGracePeriodSeconds`.\n- **Configurable signals** — defaults to `SIGINT` + `SIGTERM`, override via\n  `Config.Signals`.\n- **Structured logging** — every phase logs begin/end with duration; plug\n  any logger that implements the 3-method `Logger` interface.\n- **GORM-aware** — closes the underlying `*sql.DB` of each registered\n  `*gorm.DB` with a deadline (avoids hanging on a stuck pool).\n- **Concurrent drain** — multiple `*fiber.App` instances (or anything\n  implementing `Shutdowner`) are drained in parallel under a shared\n  deadline.\n\n### Installation\n\nFor Fiber v2:\n```bash\ngo get github.com/adrielcodeco/go-tools/gsfiber\n```\n\nFor Fiber v3:\n```bash\ngo get github.com/adrielcodeco/go-tools/gsfiberv3\n```\n\nThe two adapters share an engine (`gscore`); the public surface is\nidentical apart from `*fiber.App` vs `fiber.App` and `*fiber.Ctx` vs\n`fiber.Ctx` in the readiness handler.\n\n### Public API\n\n```go\n// Manager\ngsfiber.New(cfg gsfiber.Config) *gsfiber.Manager\n\n// Registration\ngsfiber.RegisterApp(m *Manager, app *fiber.App)    // one or more\nmgr.RegisterDB(db *gorm.DB)                        // one or more\nmgr.RegisterCloser(name string, phase Phase,       // any client with a Close() method;\n    timeout time.Duration,                         // see also the gsrueidis adapter\n    fn func(ctx context.Context) error)            // for rueidis.Client.\nmgr.AddHook(gsfiber.Hook{Name, Phase, Priority, Run})\n\n// Lifecycle\nmgr.RootContext() context.Context                  // cancelled on shutdown\nmgr.IsReady() bool                                 // false once shutdown began\nmgr.Trigger()                                      // start sequence programmatically\nmgr.ListenAndWait() error                          // block on signals + run\nmgr.Wait() error                                   // block until sequence done\n\n// Readiness probe\ngsfiber.ReadinessHandler(mgr) fiber.Handler\n\n// Phases (re-exported on the adapter package)\ngsfiber.PhasePreStop\ngsfiber.PhaseDrain\ngsfiber.PhasePostDrain\ngsfiber.PhaseDB\ngsfiber.PhasePostDB\n\n// Config\ntype Config struct {\n    Signals        []os.Signal     // default: SIGINT, SIGTERM\n    PreStopDelay   time.Duration   // wait before any phase runs (default: 0)\n    DrainTimeout   time.Duration   // bound on HTTP drain (default: 25s)\n    HookTimeout    time.Duration   // bound per phase (default: 10s)\n    DBCloseTimeout time.Duration   // bound on each gorm.DB close (default: 5s)\n    ForceKillAfter time.Duration   // global ceiling, os.Exit(1) (default: 60s)\n    Logger         gscore.Logger   // structured logger; nil = silent\n    OnHookError    func(name string, phase gscore.Phase, err error)\n}\n```\n\n### Phases\n\n| Phase | Purpose |\n|---|---|\n| `PhasePreStop` | Runs first, while the server is still serving. Use for actions that need the HTTP layer alive (signal in-flight workers, flush in-memory queue). |\n| `PhaseDrain` | Drains all registered Fiber apps concurrently with `DrainTimeout`. |\n| `PhasePostDrain` | Runs after HTTP is fully drained, before DB close. Best place for outbound-call cleanups, worker pool waits, etc. |\n| `PhaseDB` | Closes each registered `*gorm.DB`'s underlying `*sql.DB` with `DBCloseTimeout`. |\n| `PhasePostDB` | Last phase. Use for resources that do not depend on the DB: Kafka producers, log flushers, metric exporters. |\n\n### Usage\n\n#### 1. Minimum setup\n\n\u003e For production services, prefer the `setup` package (see [Quick Setup](#quick-setup-setup))\n\u003e which handles registration order automatically. This example shows the\n\u003e manual wiring for when you need full control.\n\n```go\nfunc main() {\n    db := openGORM()\n    app := fiber.New()\n    app.Use(txctx.Middleware(db, txctx.Config{Timeout: 5 * time.Second}))\n    registerRoutes(app)\n\n    mgr := gsfiber.New(gsfiber.Config{\n        PreStopDelay:   5 * time.Second,  // give kube-proxy time to drop the endpoint\n        DrainTimeout:   25 * time.Second,\n        DBCloseTimeout: 5 * time.Second,\n        ForceKillAfter: 55 * time.Second, // \u003c terminationGracePeriodSeconds\n    })\n    gsfiber.RegisterApp(mgr, app)\n    mgr.RegisterDB(db)\n\n    // Register all three Kubernetes probe endpoints.\n    app.Get(\"/healthz/live\",    gsfiber.LivenessHandler())        // always 200\n    app.Get(\"/healthz/ready\",   gsfiber.ReadinessHandler(mgr))   // 503 on shutdown\n    app.Get(\"/healthz/startup\", gsfiber.StartupHandler(mgr))     // 503 until MarkStarted\n\n    // Boot sequence: complete migrations before accepting traffic.\n    // The startup probe returns 503 until MarkStarted() is called.\n    if err := runMigrations(db); err != nil {\n        log.Fatal(err)\n    }\n    mgr.MarkStarted()\n\n    // Background workers must use mgr.RootContext() so they are cancelled\n    // the instant SIGTERM is received, before the HTTP drain begins.\n    go pollWorker(mgr.RootContext())\n\n    // ListenAndTrigger starts the server and calls mgr.Trigger() if Listen\n    // fails, so the process never hangs waiting for a signal that won't come.\n    gsfiber.ListenAndTrigger(app, mgr, \":8080\")\n\n    if err := mgr.ListenAndWait(); err != nil {\n        log.Fatal(err)\n    }\n}\n```\n\n#### 2. Cancel outbound calls on shutdown\n\nDerive any long-running outbound call from `mgr.RootContext()`. It is\ncancelled the moment SIGTERM is observed, so the call aborts cleanly\nduring the drain phase.\n\n```go\ngo func() {\n    ticker := time.NewTicker(30 * time.Second)\n    defer ticker.Stop()\n    for {\n        select {\n        case \u003c-mgr.RootContext().Done():\n            return\n        case \u003c-ticker.C:\n            req, _ := http.NewRequestWithContext(mgr.RootContext(),\n                http.MethodGet, \"https://api.example/poll\", nil)\n            _, _ = http.DefaultClient.Do(req)\n        }\n    }\n}()\n```\n\nFor per-request outbound calls inside a handler, keep using\n`c.UserContext()` — `txctx` already wires its cancellation.\n\n#### 3. Ordered hooks across phases\n\n```go\nmgr.AddHook(gsfiber.Hook{\n    Name:     \"outbox-flush\",\n    Phase:    gsfiber.PhasePreStop, // before we stop accepting requests\n    Priority: 0,\n    Run: func(ctx context.Context) error {\n        return outbox.FlushAll(ctx)\n    },\n})\n\nmgr.AddHook(gsfiber.Hook{\n    Name:     \"kafka-close\",\n    Phase:    gsfiber.PhasePostDB,  // after DB is closed\n    Priority: 10,\n    Run: func(ctx context.Context) error {\n        return kafkaProducer.Close()\n    },\n})\n\nmgr.AddHook(gsfiber.Hook{\n    Name:     \"redis-close\",\n    Phase:    gsfiber.PhasePostDB,\n    Priority: 0, // runs before kafka-close (lower priority first)\n    Run: func(ctx context.Context) error {\n        return redisClient.Close()\n    },\n})\n```\n\nLower `Priority` runs first within the same phase; equal priorities run in\nregistration order.\n\n#### 4. Custom logger\n\nAny type that satisfies the three-method `gscore.Logger` interface works\n(slog, zap, zerolog, logrus, etc.).\n\n```go\ntype slogAdapter struct{ l *slog.Logger }\n\nfunc (s slogAdapter) Info(msg string, kv ...any)  { s.l.Info(msg, kv...) }\nfunc (s slogAdapter) Warn(msg string, kv ...any)  { s.l.Warn(msg, kv...) }\nfunc (s slogAdapter) Error(msg string, kv ...any) { s.l.Error(msg, kv...) }\n\nmgr := gsfiber.New(gsfiber.Config{\n    Logger: slogAdapter{l: slog.Default()},\n})\n```\n\n#### 5. Triggering shutdown programmatically\n\n`mgr.Trigger()` starts the sequence from anywhere — useful for fatal\nerrors caught outside the HTTP layer (e.g. a background worker losing a\ncritical connection).\n\n```go\nif err := kafkaConsumer.Run(mgr.RootContext()); err != nil \u0026\u0026 !errors.Is(err, context.Canceled) {\n    log.Printf(\"consumer fatal: %v\", err)\n    mgr.Trigger()\n}\n```\n\n`Trigger` is idempotent — the sequence runs exactly once regardless of\nhow many times it is called or whether a signal also arrives.\n\n### Kubernetes integration\n\n#### Health probe handlers\n\nThe package ships three handlers, one per Kubernetes probe type:\n\n| Handler | Probe | Endpoint | Behaviour |\n|---|---|---|---|\n| `LivenessHandler()` | `livenessProbe` | `/healthz/live` | Always `200`. If the process responds, it is alive. **No external dependencies.** |\n| `ReadinessHandler(mgr)` | `readinessProbe` | `/healthz/ready` | `200` while ready, `503` the instant shutdown begins. Drives kube-proxy endpoint removal. |\n| `StartupHandler(mgr)` | `startupProbe` | `/healthz/startup` | `503` until `mgr.MarkStarted()` is called, then `200`. Protects slow-boot pods (migrations, cache warm-up). |\n\n**Via `setup.Builder` (recommended):** `WithHealthProbesV2` / `WithHealthProbesV3`\nregisters all three routes automatically during `Build`:\n\n```go\nmgr := gscore.New(gscore.Config{...})\n_, err := setup.New().\n    WithFiberV2(app).\n    WithHealthProbesV2(setup.HealthProbesConfig{}).  // zero value = default paths\n    WithGORM(db).\n    Build(mgr)\n\n// Boot sequence: WithStartupFn runs migrations and calls MarkStarted\n// automatically on success — startup probe flips to 200 after Build returns.\n// Build itself returns an error if migrations fail, so the process exits\n// cleanly before ever calling Listen.\ngsfiber.ListenAndTrigger(app, mgr, \":8080\")\nmgr.ListenAndWait()\n```\n\n**Manual registration** (without `setup`):\n\n```go\napp.Get(\"/healthz/live\",    gsfiber.LivenessHandler())\napp.Get(\"/healthz/ready\",   gsfiber.ReadinessHandler(mgr))\napp.Get(\"/healthz/startup\", gsfiber.StartupHandler(mgr))\n\nif err := runMigrations(db); err != nil {\n    log.Fatal(err)\n}\nmgr.MarkStarted() // ← startup probe flips to 200\n\ngsfiber.ListenAndTrigger(app, mgr, \":8080\")\nmgr.ListenAndWait()\n```\n\n\u003e **Rule of thumb:** never check databases, caches, or any external\n\u003e dependency inside `LivenessHandler`. A slow dependency would cause\n\u003e liveness to fail → Kubernetes restarts the pod → cascading restarts\n\u003e across the fleet.\n\n---\n\n#### Recommended Kubernetes manifest\n\n```yaml\nspec:\n  terminationGracePeriodSeconds: 60   # must be \u003e ForceKillAfter\n  containers:\n  - name: api\n    # --- startup probe -------------------------------------------\n    # Kubernetes suspends liveness + readiness until this passes once.\n    # Gives slow-boot pods (migrations, warm-up) time to initialize\n    # without being killed by a failing liveness probe.\n    startupProbe:\n      httpGet:\n        path: /healthz/startup\n        port: 8080\n      # Allow up to 5 min for boot: periodSeconds(10) × failureThreshold(30)\n      periodSeconds: 10\n      failureThreshold: 30\n      timeoutSeconds: 2\n\n    # --- liveness probe ------------------------------------------\n    # Restarts the pod if the process stops responding entirely.\n    # Keep it cheap: no DB, no cache, no external calls.\n    livenessProbe:\n      httpGet:\n        path: /healthz/live\n        port: 8080\n      initialDelaySeconds: 0   # startupProbe already guards the boot window\n      periodSeconds: 10\n      failureThreshold: 3\n      timeoutSeconds: 2\n\n    # --- readiness probe -----------------------------------------\n    # Removes the pod from service endpoints during shutdown.\n    # Tight period + low threshold so kube-proxy reacts quickly.\n    readinessProbe:\n      httpGet:\n        path: /healthz/ready\n        port: 8080\n      periodSeconds: 2\n      failureThreshold: 1\n      timeoutSeconds: 1\n\n    lifecycle:\n      preStop:\n        exec:\n          # Belt-and-suspenders sleep in case SIGTERM races with\n          # kube-proxy propagation. The Manager's PreStopDelay\n          # provides the same guarantee in-process.\n          command: [\"sleep\", \"5\"]\n```\n\n**Timing relationships that must hold:**\n\n```\nstartupProbe:  periodSeconds × failureThreshold  ≥  expected max boot time\nlivenessProbe: does NOT query DB / cache / external services\nForceKillAfter  \u003c  terminationGracePeriodSeconds  (e.g. 55s \u003c 60s)\nPreStopDelay    ≥  readinessProbe.periodSeconds   (e.g. 5s ≥ 2s)\n```\n\n---\n\n#### Shutdown sequence on `kubectl delete pod`\n\n1. Kubernetes sends `SIGTERM` and starts the `preStop` hook (in parallel).\n2. The Manager observes the signal → flips readiness to `503` → starts\n   `PreStopDelay`.\n3. kube-proxy sees the failing readiness probe and removes the pod from\n   service endpoints → no new requests arrive.\n4. `PreStopDelay` elapses → hooks run → HTTP drain → DB close → post-DB\n   hooks.\n5. Process exits cleanly, well before\n   `terminationGracePeriodSeconds`.\n\nKeep `ForceKillAfter` strictly **less than** `terminationGracePeriodSeconds`\nso the Manager's own ceiling fires first, with logs you can read, instead\nof an abrupt `SIGKILL` from the kubelet.\n\n---\n\n## Rueidis (`gsrueidis`)\n\n[Rueidis](https://github.com/redis/rueidis) clients expose a synchronous\n`Close()` with no context and no error return — it waits for every\nin-flight pipelined command and PubSub subscriber to settle. In a\nmisbehaving setup that wait can be indefinite. The `gsrueidis` submodule\nruns `Close()` in a goroutine bounded by the closer's timeout so a\nwedged Redis client cannot stall the rest of the shutdown sequence.\n\n```bash\ngo get github.com/adrielcodeco/go-tools/gsrueidis\n```\n\n```go\ngsrueidis.RegisterRueidis(mgr, \"redis-cache\", client,\n    gscore.PhasePostDB, 5*time.Second)\n```\n\n- `PhasePostDB` is the recommended phase: any `txctx.OnCommit` callback\n  that publishes to Redis after a DB commit must have completed during\n  the drain, so it's safe to tear the client down.\n- `timeout=0` falls back to `gsrueidis.DefaultTimeout` (5s) — Redis\n  hangs deserve a dedicated default rather than `Config.HookTimeout`.\n- If `Close()` does not return within the timeout, the registered closer\n  returns `gsrueidis.ErrCloseTimedOut`. The manager logs it (and calls\n  `OnHookError` if set) and continues to the next closer.\n\nFor non-rueidis clients, the underlying `gscore.RegisterCloser` is\npublic — use it directly to register any cleanup function that has a\nsimilar \"blocking Close()\" shape (Kafka, gRPC pools, etc.).\n\n### Tracing rueidis with Elastic APM\n\nThere is no dedicated `apmrueidis` package — rueidis already publishes\nan official OTel adapter\n([`rueidisotel`](https://pkg.go.dev/github.com/redis/rueidis/rueidisotel)),\nand `apmcore.SetupOTelSDK` plants the APM agent as the OTel global\n`TracerProvider`/`MeterProvider`. Construct the client via `rueidisotel`\nand spans + metrics flow through APM automatically:\n\n```go\nimport (\n    \"github.com/redis/rueidis\"\n    \"github.com/redis/rueidis/rueidisotel\"\n\n    \"github.com/adrielcodeco/go-tools/apmcore\"\n)\n\nfunc main() {\n    shutdown, _ := apmcore.SetupOTelSDK(context.Background())\n    defer shutdown(context.Background())\n\n    client, err := rueidisotel.NewClient(rueidis.ClientOption{\n        InitAddress: []string{\"localhost:6379\"},\n    })\n    // ... use client as usual; spans appear in Kibana → APM → Services.\n}\n```\n\nNotes:\n\n- `rueidisotel.NewClient` is the only way to get **pool-level metrics**\n  (it has to install its own `DialFn`). `rueidisotel.WithClient(existing)`\n  wraps an already-built client but only adds tracing — no pool metrics.\n- `apmcore.InstrumentRedis(client)` is for `redis/go-redis/v9` clients\n  (it uses `redisotel`). It does **not** apply to rueidis.\n- The `SetupOTelSDK` call must happen before `rueidisotel.NewClient`, so\n  the client picks up the APM-backed `TracerProvider`/`MeterProvider`.\n\n---\n\n## Redis graceful shutdown (`gsredis`)\n\nThe `gsredis` package is the [go-redis/v9](https://github.com/redis/go-redis)\n`UniversalClient` analog of `gsrueidis`. It registers `client.Close()` as a\ncontext-aware closer on the Manager, bounded by a per-client timeout so a slow\nRedis pool cannot stall the shutdown sequence.\n\n```bash\ngo get github.com/adrielcodeco/go-tools/gsredis\n```\n\n```go\n// Register for graceful shutdown (Close bounded by timeout).\ngsredis.Register(client, mgr, gscore.PhasePostDB, 5*time.Second)\n\n// Register + instrument with OTel tracing and metrics in one call.\nif err := gsredis.InstrumentAndRegister(client, mgr, gscore.PhasePostDB, 5*time.Second); err != nil {\n    // instrumentation failed; tracing is best-effort — client is still registered.\n    log.Printf(\"redis instrumentation: %v\", err)\n}\n```\n\n- `timeout=0` falls back to `gsredis.DefaultTimeout` (5s).\n- If `Close()` does not return within the timeout the registered closer\n  returns `gsredis.ErrCloseTimedOut`. The Manager logs it and continues.\n- `InstrumentAndRegister` calls `apmcore.InstrumentRedis(client)` before\n  registering the closer. Call `apmcore.SetupOTelSDK` first so the client\n  picks up the APM-backed `TracerProvider`.\n- `PhasePostDB` is recommended for the same reason as `gsrueidis`: any\n  `txctx.OnCommit` callbacks that write to Redis will have completed by then.\n\n---\n\n## GORM Auto-batch (`gormautobatch`)\n\nA GORM plugin that transparently switches between individual and batched\ndatabase operations based on measured P95 write latency. When latency exceeds\nthe configured threshold, `Create`/`Updates`/`Delete` calls are buffered and\nflushed as a single transaction, reducing round-trips under load. When latency\ndrops back, operations pass through normally with no overhead.\n\n```bash\ngo get github.com/adrielcodeco/go-tools/gormautobatch\n```\n\n```go\nimport autobatch \"github.com/adrielcodeco/go-tools/gormautobatch\"\n\nthreshold := 50 * time.Millisecond\np := autobatch.New(autobatch.Config{\n    LatencyThreshold: \u0026threshold,            // nil = disabled; 0 = always batch; \u003e0 = adaptive\n    FlushTimeout:     10 * time.Millisecond, // flush batch after 10ms idle\n    MaxBatchSize:     100,                   // or when 100 ops are buffered\n    WindowDuration:   30 * time.Second,      // P95 measured over last 30s\n})\nif err := db.Use(p); err != nil {\n    log.Fatal(err)\n}\ndefer p.Close() // drain in-flight batches before exit\n```\n\nRegular GORM calls are unchanged — the plugin decides whether to batch\ntransparently. `db.Create(\u0026u)`, `db.Model(\u0026u).Updates(\u0026payload)`, and\n`db.Delete(\u0026r)` all participate. `Find`/`First` are never buffered.\n\n### Batch semantics\n\nAll operations in a batch run inside a single transaction. Each individual\noperation is wrapped in its own `SAVEPOINT`, so a per-op failure (e.g. a\nunique-constraint violation) is isolated: only the failing caller sees the\nerror, and the rest of the batch still commits. Callers block synchronously\nuntil their batch is flushed — from the caller's perspective it looks like a\nnormal GORM call.\n\nOperations inside `db.Transaction(...)` or `db.Begin()` are never batched —\nthey run inline on the user's transaction to preserve atomicity.\n\n### Graceful shutdown integration\n\nRegister the plugin with the Manager so in-flight batches are drained before\nthe DB pool closes:\n\n```go\nautobatch.RegisterWithManager(p, mgr, int(gscore.PhasePostDrain), 30*time.Second)\n```\n\nOr let `setup.New().WithAutobatchConfig(cfg).Build(mgr)` handle this\nautomatically.\n\n### APM tracing\n\nWhen `WithOTel` is set in the `setup` builder, batched flush transactions\nautomatically appear as APM spans via `apmcore.BatchSpanEmitter()`. To wire\nthis manually:\n\n```go\ncfg.SpanEmitter = apmcore.BatchSpanEmitter()\np := autobatch.New(cfg)\n```\n\n### DBResolver compatibility\n\nDBResolver must be registered before autobatch. Multi-source (sharded) primary\nconfigurations are not supported — batched writes are routed to the pool\nselected at `BEGIN` time. Single-primary + read-replica setups are fully\nsupported.\n\n---\n\n## HTTP client (`httpclient`)\n\nA small fasthttp-based client wrapper with generics, sonic JSON,\nconfigurable retry, and APM tracing already wired through `apmcore`.\nEach call produces an APM exit span (via `apmcore.TraceFastHTTPCall`)\nand propagates the active transaction's `traceparent` header.\n\n```bash\ngo get github.com/adrielcodeco/go-tools/httpclient\n```\n\n```go\ntype charge struct {\n    ID     string `json:\"id\"`\n    Amount int    `json:\"amount\"`\n}\n\nout, err := httpclient.POST[charge](ctx, httpclient.RequestOptions{\n    URL:     \"https://api.example.com/charge\",\n    Headers: httpclient.JSONHeaders(),\n    Data:    map[string]any{\"amount\": 100},\n    Retry: httpclient.RetryPolicy{\n        MaxAttempts:    3,\n        InitialBackoff: 100 * time.Millisecond,\n    },\n})\n```\n\n### Configuration\n\n```go\n// Override the underlying *fasthttp.Client (default has 30s read/write).\nhttpclient.UseClient(\u0026fasthttp.Client{ReadTimeout: 10 * time.Second})\n\n// Install a structured-log hook called once per attempt — even retries.\nhttpclient.SetHook(func(r httpclient.Record) {\n    logger.LogCtx(r.Ctx).Info(\"← outgoing\",\n        zap.String(\"method\", r.Method),\n        zap.String(\"url\", r.URL),\n        zap.Int(\"status\", r.Status),\n        zap.Duration(\"rt\", r.ResponseTime),\n        zap.Int(\"attempt\", r.Attempt),\n        zap.Error(r.Err),\n    )\n})\n```\n\n### Error handling\n\n- Transport errors (DNS, connection refused, timeouts) are returned as-is.\n- HTTP status outside `[200, 300)` is returned as a `*httpclient.StatusError`\n  with `StatusCode` and the raw response `Body` preserved — callers can\n  inspect or decode partial responses even on failure.\n- `Request[O]` / `GET[O]` / etc. attempt to decode the body into `*O`\n  via sonic regardless of error; the returned error is the original\n  call error so retry logic still works.\n\n### Retry\n\n`RetryPolicy.ShouldRetry` defaults to retrying transport errors and 5xx\nresponses. Override for app-specific semantics (e.g. retry on 429, but\nnot 401).\n\n---\n\n## Structured logging (`logcore` / `logfiber` / `logfiberv3`)\n\nA zap-based logger pre-wired for APM, plus middlewares that emit a\nconsistent `incoming` / `outgoing` schema across the request lifecycle.\n\n```bash\ngo get github.com/adrielcodeco/go-tools/logcore\ngo get github.com/adrielcodeco/go-tools/logfiber       # Fiber v2\n# or\ngo get github.com/adrielcodeco/go-tools/logfiberv3     # Fiber v3\n```\n\n### Bootstrap\n\n```go\nl, _ := logcore.New(logcore.Options{\n    Service:     \"ledger\",\n    Version:     \"1.2.3\",\n    Environment: \"production\",\n})\nlogcore.SetGlobal(l)\n\n// Outgoing logs for httpclient.\nhttpclient.SetHook(logcore.HTTPClientHook())\n```\n\n`logcore.New` wraps the zap core with `apmzap.Core` by default — any\n`logger.Error(...)` or `logger.Fatal(...)` call is auto-emitted as an\nAPM error event in Kibana → APM → Errors. Disable with\n`Options{DisableAPMCore: true}` in tests.\n\n### Fiber middleware (incoming)\n\n```go\napp.Use(apmfiber.Middleware())          // first, so transactions exist\napp.Use(logfiber.Middleware(logfiber.Config{\n    // SkipPaths defaults to [\"/live\", \"/ready\", \"/health\"]\n}))\n```\n\nEvery request produces one log line with the schema:\n\n```json\n{\n  \"msg\": \"→ incoming → [POST] /charge - 200\",\n  \"trace.id\": \"…\",\n  \"transaction.id\": \"…\",\n  \"incoming\": {\n    \"req\":  { \"params\": …, \"queryString\": …, \"headers\": …, \"body\": … },\n    \"res\":  { \"headers\": …, \"body\": …, \"statusCode\": \"200\" },\n    \"responseTime\": \"12.4ms\"\n  }\n}\n```\n\nThe `httpclient.SetHook(logcore.HTTPClientHook())` emits the **same\nshape** under the `outgoing` key, so Kibana queries like\n`outgoing.res.body.id` match outbound calls and `incoming.req.body.id`\nmatch inbound — without separate dashboards per direction.\n\n### Trace correlation everywhere else\n\nFor any non-middleware log call, use the global helpers so the trace\nfields are added automatically:\n\n```go\nlogcore.LogCtx(ctx).Info(\"processed\", zap.String(\"id\", id))\n// → adds trace.id / transaction.id from the active APM span\n```\n\n### Graceful shutdown integration\n\nRegister the logger's `Sync()` as a shutdown closer so buffered log lines are\nflushed before the process exits. Register this last — after all other closers —\nso shutdown log lines from every earlier phase are not lost:\n\n```go\n// On a specific logger instance:\nl.RegisterWithManager(mgr, int(gscore.PhasePostDB), 0)\n\n// Or using the global logger:\nlogcore.RegisterGlobalWithManager(mgr, int(gscore.PhasePostDB), 0)\n```\n\n`phase=0` defaults to `PhasePostDB`; `timeout=0` defaults to 5s. The `setup`\nbuilder calls `logcore.RegisterGlobalWithManager` automatically as the last step\nof `Build`.\n\n### gscore.Logger adapter\n\n`logcore.GSCoreGlobalLogger()` returns a value that satisfies `gscore.Logger`\n(the three-method `Info`/`Warn`/`Error` interface), backed by the global zap\nlogger. Pass it to `gscore.Config.Logger` so shutdown phase events are emitted\nthrough the same structured logger as the rest of the application:\n\n```go\nmgr := gscore.New(gscore.Config{\n    Logger: logcore.GSCoreGlobalLogger(),\n    // ...\n})\n```\n\n---\n\n## GORM Cache (`gormcache`)\n\nA GORM plugin that reduces database load via two complementary mechanisms:\n**request deduplication** (easer) and **response caching**. The plugin ships a\n`Cacher` interface; ready-made implementations for go-redis and rueidis are\nprovided in `gsredis` and `gsrueidis`. All cache operations can be wrapped with\nOTel spans via `apmcore.InstrumentCacher`.\n\n### Features\n\n- **Request deduplication (easer)** — if N identical queries run concurrently,\n  only the first hits the database; the rest wait and receive the same result.\n- **Response caching** — implement the `Cacher` interface to plug any backend\n  (Redis, in-memory, etc.). Cache hits skip the database entirely.\n- **Granular invalidation** — every Create/Update/Delete mutation fires\n  `Cacher.Invalidate` with an `InvalidationEvent` carrying the affected tables,\n  primary key values, and mutation type.\n- **Tag-based invalidation** — tag cached queries via `Config.TagsFunc` and\n  selectively evict them with `caches.WithInvalidateTags` on mutations.\n- **Safe under concurrent load** — invalidation only fires after a mutation\n  completes without error; failed or short-circuited operations (e.g. autobatch)\n  do not evict the cache.\n- **Compatible with any gorm-supported database.**\n\n### Installation\n\n```bash\ngo get github.com/adrielcodeco/go-tools/gormcache\n```\n\nFor the ready-made Redis backend, also install:\n\n```bash\ngo get github.com/adrielcodeco/go-tools/gsredis    # go-redis backend\n# or\ngo get github.com/adrielcodeco/go-tools/gsrueidis  # rueidis backend\n```\n\n### Quick start\n\n```go\nimport (\n    \"github.com/adrielcodeco/go-tools/gormcache\"\n    \"github.com/adrielcodeco/go-tools/gsredis\"\n)\n\nredisClient := redis.NewClient(\u0026redis.Options{Addr: \"localhost:6379\"})\n\ncachesPlugin := \u0026caches.Caches{Conf: \u0026caches.Config{\n    Easer:  true,                                          // enable request deduplication\n    Cacher: gsredis.NewRedisCacher(redisClient, 5*time.Minute), // Redis backend\n}}\n\nif err := db.Use(cachesPlugin); err != nil {\n    log.Fatal(err)\n}\n```\n\nAll subsequent `db.Find`, `db.First`, etc. calls are intercepted: cache hits\nreturn immediately; misses run the query and store the result. `db.Create`,\n`db.Save`, `db.Updates`, and `db.Delete` trigger `Cacher.Invalidate`\nautomatically.\n\n### Easer (request deduplication)\n\nWhen `Config.Easer = true`, identical concurrent queries are coalesced: the\nfirst goroutine to arrive executes the query; all others block until it\ncompletes and receive a deep copy of the result. This eliminates thundering-herd\nbehaviour for hot read paths without any cache backend.\n\n```go\ncachesPlugin := \u0026caches.Caches{Conf: \u0026caches.Config{\n    Easer: true,\n}}\n_ = db.Use(cachesPlugin)\n\n// The two concurrent Find calls below share one DB roundtrip.\nvar (\n    q1Users []UserModel\n    q2Users []UserModel\n)\nwg := \u0026sync.WaitGroup{}\nwg.Add(2)\ngo func() {\n    db.Model(\u0026UserModel{}).Joins(\"Role\").Find(\u0026q1Users, \"Role.Name = ?\", \"Admin\")\n    wg.Done()\n}()\ngo func() {\n    time.Sleep(50 * time.Millisecond)\n    db.Model(\u0026UserModel{}).Joins(\"Role\").Find(\u0026q2Users, \"Role.Name = ?\", \"Admin\")\n    wg.Done()\n}()\nwg.Wait()\n```\n\n### Cacher interface\n\nImplement three methods to plug any cache backend:\n\n```go\ntype Cacher interface {\n    // Get returns the cached result for key, or (nil, nil) on a miss.\n    Get(ctx context.Context, key string, q *Query[any]) (*Query[any], error)\n\n    // Store persists the query result under key.\n    // Use caches.TagsFromContext(ctx) to read tags set by TagsFunc.\n    Store(ctx context.Context, key string, val *Query[any]) error\n\n    // Invalidate evicts cache entries based on the mutation event.\n    // event.Tags is populated from WithInvalidateTags; event.Tables and\n    // event.EntityIDs are always populated from the GORM statement.\n    Invalidate(ctx context.Context, event *InvalidationEvent) error\n}\n```\n\n`Query[T]` provides `Marshal() ([]byte, error)` and `Unmarshal([]byte) error`\nfor serialisation. Use them in `Store` and `Get`:\n\n```go\nfunc (c *myCacher) Store(ctx context.Context, key string, val *caches.Query[any]) error {\n    b, err := val.Marshal()  // JSON\n    if err != nil {\n        return err\n    }\n    return c.backend.Set(ctx, key, b, c.ttl)\n}\n\nfunc (c *myCacher) Get(ctx context.Context, key string, q *caches.Query[any]) (*caches.Query[any], error) {\n    b, err := c.backend.Get(ctx, key)\n    if err != nil {\n        return nil, nil  // treat missing key as a miss\n    }\n    if err := q.Unmarshal(b); err != nil {\n        return nil, err\n    }\n    return q, nil\n}\n```\n\n### Ready-made backends\n\nBoth backends index cache keys by tag (via Redis `SADD tag:\u003ctag\u003e \u003ckey\u003e`) so\n`Invalidate` can evict exactly the right keys without a full-cache scan.\n\n#### go-redis (`gsredis.NewRedisCacher`)\n\n```go\nimport \"github.com/adrielcodeco/go-tools/gsredis\"\n\ncacher := gsredis.NewRedisCacher(redisClient, 5*time.Minute)\n// ttl=0 → keys persist until explicitly invalidated\n```\n\n#### rueidis (`gsrueidis.NewRueidisCache`)\n\n```go\nimport \"github.com/adrielcodeco/go-tools/gsrueidis\"\n\ncacher := gsrueidis.NewRueidisCache(rueidisClient, 5*time.Minute)\n```\n\nRueidis pipelines all `Store` commands (`SET` + `SADD` + `EXPIRE`) in a single\n`DoMulti` call, and `Invalidate` deletes all member keys in a single\nmulti-key `DEL`, making it the higher-throughput option for write-heavy\ninvalidation workloads.\n\n\u003e **Tag index note:** both backends build the tag→key index only from tags\n\u003e provided via `Config.TagsFunc`. If `TagsFunc` is not set, `Invalidate` is\n\u003e a no-op (entries expire via TTL). To invalidate by table, emit the table\n\u003e name as a tag:\n\u003e\n\u003e ```go\n\u003e TagsFunc: func(db *gorm.DB) []string {\n\u003e     return []string{db.Statement.Table}\n\u003e },\n\u003e ```\n\n### Tag-based invalidation\n\nTags let you selectively evict cache entries, similar to TanStack Query's query\nkeys. Instead of wiping the full cache on every mutation, you tag queries and\nonly evict the relevant ones.\n\n**Tag queries** via `Config.TagsFunc`:\n\n```go\ncachesPlugin := \u0026caches.Caches{Conf: \u0026caches.Config{\n    Cacher: cacher,\n    TagsFunc: func(db *gorm.DB) []string {\n        return []string{db.Statement.Table}\n    },\n}}\n```\n\n**Invalidate by tag** via `caches.WithInvalidateTags` on the mutation context:\n\n```go\nctx := caches.WithInvalidateTags(context.Background(), \"users\")\ndb.WithContext(ctx).Create(\u0026User{Name: \"John\"})\n// → fires Cacher.Invalidate with event.Tags = [\"users\"]\n```\n\n**`InvalidationEvent` fields:**\n\n| Field | Type | Description |\n|---|---|---|\n| `Tables` | `[]string` | Tables involved in the mutation |\n| `EntityIDs` | `[]interface{}` | Primary key values of affected entities |\n| `MutationType` | `MutationType` | `MutationCreate`, `MutationUpdate`, or `MutationDelete` |\n| `Tags` | `[]string` | Tags from `WithInvalidateTags` (empty if not set) |\n\n**Minimal invalidation example** (in-memory backend):\n\n```go\nfunc (c *memoryCacher) Invalidate(ctx context.Context, event *caches.InvalidationEvent) error {\n    if len(event.Tags) \u003e 0 {\n        return c.invalidateByTags(event.Tags)\n    }\n    // No tags: fall back to wiping everything.\n    c.store = \u0026sync.Map{}\n    return nil\n}\n```\n\n### OTel instrumentation\n\nWrap any `Cacher` with `apmcore.InstrumentCacher` to emit OTel spans for every\n`Get`, `Store`, and `Invalidate` call. Spans are named `gormcache.get`,\n`gormcache.store`, and `gormcache.invalidate`, and include `cache.hit` (bool),\n`cache.tags` (count), and `db.tables` attributes. Errors are recorded and the\nspan status is set to `Error` so APM error-rate metrics fire correctly.\n\n```go\nimport \"github.com/adrielcodeco/go-tools/apmcore\"\n\ncachesPlugin := \u0026caches.Caches{Conf: \u0026caches.Config{\n    Cacher: apmcore.InstrumentCacher(\n        gsredis.NewRedisCacher(redisClient, 5*time.Minute),\n    ),\n}}\n```\n\n### setup integration\n\n`setup.Builder` wires gormcache in the correct position (after `gormautobatch`,\nso autobatch's batch callbacks fire before cache invalidation callbacks). When\n`WithOTel` is also set, `Build` automatically wraps the `Cacher` with\n`apmcore.InstrumentCacher`.\n\n```go\nresult, err := setup.New().\n    WithGORM(db).\n    WithOTel(ctx).\n    WithRedis(redisClient, \"redis-cache\").\n    WithGORMCacheConfig(caches.Config{\n        Easer:  true,\n        Cacher: gsredis.NewRedisCacher(redisClient, 5*time.Minute),\n        TagsFunc: func(db *gorm.DB) []string {\n            return []string{db.Statement.Table}\n        },\n    }).\n    Build(mgr)\n```\n\n`WithGORMCache(p *caches.Caches)` is also available when you need to construct\nthe plugin yourself before passing it to `Build`.\n\n---\n\n## Elastic APM (`apmfiber` / `apmfiberv3`)\n\nWraps the [Elastic APM Go agent](https://www.elastic.co/guide/en/apm/agent/go/current/index.html)\ninto the same core-plus-adapter shape used by the rest of this toolbox.\n\n### Packages\n\n| Submodule | Import | Purpose |\n|---|---|---|\n| `apmcore` | `github.com/adrielcodeco/go-tools/apmcore` | Bootstrap + OTel bridge, DB driver wrapper, GORM plugin, pool metrics, zap helpers |\n| `apmfiber` | `github.com/adrielcodeco/go-tools/apmfiber` | Fiber v2 middleware + labels + error capture |\n| `apmfiberv3` | `github.com/adrielcodeco/go-tools/apmfiberv3` | Fiber v3 middleware + labels + error capture |\n\nEach submodule has its own `go.mod` so projects that don't need APM aren't\nforced to pull the Elastic + OTel dependency tree.\n\n### Features\n\n- **One-call bootstrap** — `apmcore.SetupOTelSDK(ctx)` wires the APM agent\n  into the OTel global providers and registers an `apm.MetricsGatherer`.\n  Returns a shutdown func to call after the server drains.\n- **Fiber middleware** — `apmfiber.Middleware()` / `apmfiberv3.Middleware()`\n  starts an APM transaction per request, names it `\u003cMETHOD\u003e \u003croute\u003e`, and\n  attaches it to the underlying `*fasthttp.RequestCtx`. Fiber v3 has no\n  upstream adapter — this package ships one.\n- **Transaction labels** — `Labels(LabelsConfig{...})` decodes a typed\n  struct from the request body once and publishes business identifiers\n  (`wallet_id`, `external_id`, …) as `labels.\u003ckey\u003e` filters in Kibana.\n- **Inline error capture** — `CaptureError(c, err)` records an error\n  against the active transaction so handler-mapped errors (that never\n  bubble to Fiber's `ErrorHandler`) still appear in Kibana → APM → Errors.\n- **Foldable DB spans** — `apmcore.NewGormPlugin()` + a driver wrap via\n  `apmcore.RegisterDriver(name, baseDriver)` produce a parent gorm span\n  per logical operation, with prepare/exec/query/close spans nested\n  inside. Works with any `database/sql` driver — pgx, mysql, sqlite —\n  because the base driver is passed in.\n- **DB-pool metrics** — `apmcore.RegisterDBPoolMetrics(sqlDB)` emits\n  `db.pool.*` on the agent's metrics tick (chartable in Metrics Explorer).\n- **HTTP / Redis / zap helpers** — `WrapHTTPTransport`, `InstrumentRedis`,\n  `WrapZapCore`, `LogCtxFields` cover the surrounding instrumentation\n  surface without forcing a particular client style.\n\n### Installation\n\n```bash\ngo get github.com/adrielcodeco/go-tools/apmcore\ngo get github.com/adrielcodeco/go-tools/apmfiber       # Fiber v2\n# or\ngo get github.com/adrielcodeco/go-tools/apmfiberv3     # Fiber v3\n```\n\nConfigure the agent via the standard `ELASTIC_APM_*` environment\nvariables (`ELASTIC_APM_SERVER_URL`, `ELASTIC_APM_SERVICE_NAME`, …). The\nagent ignores `OTEL_*` variables — set both if you need the same value\nin both subsystems.\n\n### Quick start (Fiber v2)\n\n```go\nfunc main() {\n    shutdown, err := apmcore.SetupOTelSDK(context.Background())\n    if err != nil { panic(err) }\n    http.DefaultTransport = apmcore.WrapHTTPTransport(http.DefaultTransport)\n\n    app := fiber.New()\n    app.Use(apmfiber.Middleware())          // must be first\n    app.Use(apmfiber.Labels(apmfiber.LabelsConfig{\n        Headers: map[string]string{\"X-Origin\": \"origin\"},\n    }))\n\n    go app.Listen(\":8080\")\n    // ... wait for shutdown signal, then drain ...\n    _ = shutdown(context.Background())\n}\n```\n\n### Manager integration\n\n`apmcore` ships two helpers for registering shutdown actions with the\ngraceful-shutdown Manager without importing `gscore` directly:\n\n```go\nshutdown, err := apmcore.SetupOTelSDK(ctx)\n\n// Flush OTel spans and metrics after the DB pool closes.\napmcore.RegisterWithManager(shutdown, mgr, int(gscore.PhasePostDB), 0)\n\n// Deregister the DB-pool metrics gatherer (avoids a zeroed-metrics tick\n// after the pool closes). Call after mgr.RegisterDB(db).\nsqlDB, _ := db.DB()\napmcore.RegisterDBPoolMetricsWithManager(sqlDB, mgr, int(gscore.PhasePostDB), 0)\n```\n\nBoth helpers accept `phase=0` to default to `PhasePostDB` and `timeout=0` to\nuse the built-in defaults (15s for the OTel shutdown; 5s for pool metrics).\n\n#### InstrumentRueidis\n\n`apmcore.InstrumentRueidis(client)` wraps a rueidis client with the OTel\n`rueidisotel` adapter in a single call. Use it when you already hold a\n`rueidis.Client` and want to add tracing without rebuilding the client:\n\n```go\n// SetupOTelSDK must have been called first.\ntracedClient := apmcore.InstrumentRueidis(existingClient)\n```\n\nFor new clients, prefer `rueidisotel.NewClient` directly so pool-level metrics\nare also captured (see [Tracing rueidis with Elastic APM](#tracing-rueidis-with-elastic-apm)).\n\n### APM transaction context in txctx\n\nWhen `apmfiber.Middleware()` and `txctx.Middleware()` are both in the chain, the\ntxctx middleware must inherit the APM transaction from the Fiber request context,\nnot from `context.Background()`. Pass `apmfiber.TxContextExtractor()` as the\nthird argument to `txctx.Middleware`:\n\n```go\n// Fiber v2\napp.Use(apmfiber.Middleware())   // must be first\napp.Use(txctx.Middleware(db, txctx.Config{Timeout: 5 * time.Second},\n    apmfiber.TxContextExtractor()))\n```\n\n```go\n// Fiber v3\napp.Use(apmfiberv3.Middleware())\napp.Use(txctxv3.Middleware(db, txctxv3.Config{Timeout: 5 * time.Second},\n    apmfiberv3.TxContextExtractor()))\n```\n\nWithout the extractor, the transaction context would be derived from\n`context.Background()` and the DB spans created inside handlers would not be\nnested under the request's APM transaction.\n\n### Local stack\n\nA reference `docker-compose.apm.yml` lives in `examples/`. It bootstraps\nElasticsearch + Kibana + APM Server (8.13.4) with security enabled, the\nAPM integration pre-installed, and recommended agent central\nconfiguration applied — no manual Kibana clicks required.\n\n```bash\ndocker compose -f examples/docker-compose.apm.yml up -d\nopen http://localhost:5601    # elastic / changeme\n```\n\n### Pitfall index\n\n- Read the active transaction via `c.Context()` (Fiber v2) or\n  `c.RequestCtx()` (Fiber v3), **not** `c.UserContext()` — the agent\n  stores the transaction on the underlying `*fasthttp.RequestCtx`.\n- Set `ELASTIC_APM_*` env vars; the agent ignores `OTEL_*`.\n- Call `apmcore.SetupOTelSDK` **before** opening DB/Redis clients so\n  they pick up the global TracerProvider.\n- `span, _ := apm.StartSpan(ctx, …)` discards the new ctx and breaks\n  span nesting — use `span, ctx := …`.\n- For foldable DB spans, the gorm plugin reassigns\n  `tx.Statement.Context`; preserve it through your repositories with\n  `db.WithContext(ctx)`.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fadrielcodeco%2Fgo-tools","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fadrielcodeco%2Fgo-tools","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fadrielcodeco%2Fgo-tools/lists"}