Skip to content

Latest commit

 

History

History
166 lines (122 loc) · 23.7 KB

File metadata and controls

166 lines (122 loc) · 23.7 KB

AGENTS.md

Guidance for AI coding assistants working in duynhlab/pkg. Read this file before making changes.

Contribution workflow for AI agents

  • No attribution trailers. Never add Co-authored-by, Generated-by, Signed-off-by, Assisted-by, or any AI/tool attribution to commits.
  • Commit message format: Subject in imperative mood ("Add feature X" instead of "Adding feature X"), capitalized, no trailing period, ≤50 characters. Prefix with the module when the change is scoped to one: obsx: Add metric exporter. Body wrapped at 72 columns, explaining what and why. No @mentions or #123 issue references in the commit — put those in the PR description.
  • Trim verbiage: in PR descriptions, commit messages, and code comments. No marketing prose, no restating the diff, no emojis.
  • Rebase, don't merge: Never merge main into the feature branch; rebase onto the latest main and push with --force-with-lease.
  • Branch names: <type>/<desc> where <type> ∈ feat fix chore docs refactor ci. Never push to main; PRs are squash-merged.
  • Pre-PR gate: make test-<module> must pass for every module you touched. It runs tidy, fmt, vet, lint and tests in one shot. Run make tidy to tidy all affected modules.
  • Backward compatibility is mandatory. These modules are consumed by all platform services. Breaking changes to exported APIs, function signatures, or behavior will be rejected. Design additive changes.
  • Tests: New features, improvements and fixes must have test coverage. Follow existing patterns in the module you're modifying. Run tests locally before pushing.

Code quality

Before submitting code, review your changes for the following:

  • No unchecked I/O. Close HTTP response bodies, sql.Rows, and file handles in defer statements. Check and propagate errors from I/O operations.
  • Context everywhere. Every exported function that performs I/O takes ctx context.Context as its first parameter. Never store a context in a struct field.
  • Error handling. Wrap errors with %w for chain inspection. Do not swallow errors silently. Return errors that help callers diagnose the issue without leaking credentials, tokens, or connection strings.
  • No panics. Never use panic in library code. Return errors and let callers decide. The explicit Must* variants (flagx.MustEnum, temporalx.MustVersioningFromEnv) exist for fail-fast startup and must stay the only exception.
  • No hardcoded defaults for security settings. TLS verification stays enabled by default. authmw must fail closed — an unparseable or absent token is a rejection, never a pass-through; a JWKS that never loaded is a 503, not a bypass.
  • No secrets in telemetry. Never put tokens, passwords, DSNs, or request bodies into span attributes, metric labels, or log fields. dbx deliberately never enables otelpgx query-parameter capture — bind parameters are PII/secrets.
  • Bounded cardinality. Metric labels and span attributes must never contain user IDs, request IDs, full URLs with path params, or any unbounded value. Use route patterns (/orders/{id}), not concrete paths. flagx values are bounded by construction so they are safe as metric labels — keep that property.
  • Resource cleanup. Every constructor that owns a goroutine, pool, or connection returns a Close/Shutdown function. Use defer and t.TempDir() in tests.
  • Thread safety. These packages run in concurrent request handlers and Temporal workers. Do not introduce shared mutable state without synchronization.
  • Minimal surface. Every exported type, function, and method is a backward-compatibility commitment consumed by multiple services. Minimize new exports. Prefer unexported struct fields with functional options over exported config structs.

Project overview

duynhlab/pkg is the shared Go SDK for the platform's microservices (auth, user, product, cart, order, review, shipping, notification, payment, checkout — all built as web → logic → core layered services). It is a multi-module monorepo — there is no top-level go.mod; all code lives in the 12 per-directory modules, each independently versioned and tagged (e.g. httpx/v0.36.0, obsx/v0.36.0, logger/slogx/v0.2.0). Services import specific modules from this repo.

The repository provides: generated gRPC/protobuf contracts, structured logging, OpenTelemetry bootstrap, HTTP and gRPC transport helpers, authentication middleware, database access, migrations, startup flags, idempotency handling, and Temporal client/worker helpers.

Repository layout

There is no top-level go.mod — the single-module line (github.com/duynhlab/pkg) is frozen at v0.35.0 and will never publish another version. Each of the 12 modules has its own go.mod:

  • proto/ — one module holding the versioned gRPC contracts for all services (cart, inventory, notification, order, payment, product, review, shipping, each under <svc>/v1/). Source .proto files and the committed generated .pb.go stubs live together; buf drives codegen from the repo root.
  • logger/slogx/ — the application logging facade (RFC-0031 / ADR-070): one context-first log/slog API that renders a single redacted record to the platform JSON envelope on stdout and over OTLP, with a mandatory privacy boundary and Event for catalog records. Every service logs through it.
  • flagx/ — startup-validated environment flags (Enum/MustEnum, Percent/MustPercent). Values are read and validated once at startup, fail fast, and are bounded by construction so they are safe as metric labels.
  • httpx/ — shared HTTP helpers on gin: consistent error responses (RespondError) and pagination (ParsePage, NewPaginated).
  • grpcx/ — gRPC server and client helpers for east-west calls: NewServer (otelgrpc stats handler, health service, reflection, panic recovery, access logging), Dial (otelgrpc, round_robin over dns:///, default per-RPC deadline), machine-readable error reasons (reasons.go), telemetry filters.
  • authmw/ — fail-closed gin OIDC middleware. NewVerifier(Config{Issuer, Audience, ...}) + MiddlewareJWT(verifier) verify bearer tokens (pinned algorithm, default RS256) against a cached, background-refreshed JWKS (URL derived from the issuer's Keycloak certs path unless overridden, refresh interval via JWKSCacheTTL); missing/invalid token or empty sub → 401, JWKS never loaded or nil verifier → 503 (still denies). Sets user_id/username/email/roles on the gin context (roles normalized from the RolesClaimPath claim, default realm_access.roles); MiddlewareRequireRole(role) gates on a role with a 403 FORBIDDEN envelope, fail-closed on absent context.
  • idempotency/ — Stripe-style idempotency-key handling: Record, sentinel errors (ErrConflict, ErrLocked, ErrNotFound), and a Postgres-backed Repository (Claim/Checkpoint/Release/Finish/Reap) that takes a *pgxpool.Pool directly. The required DDL ships as a doc comment; this module owns no migrations.
  • dbx/ — Postgres pool construction pre-wired for OpenTelemetry: NewPool(ctx, dsn, opts...) applies transaction-mode-pooler-safe settings, an otelpgx tracer with safe defaults (bounded span names, no connection details, never query parameters), and pgxpool stat metrics. Uses the OTel API only; providers are injected via options and default to the globals.
  • migratex/ — embedded SQL schema migrations with golang-migrate: Run(fsys, dir, dsn). Accepts a DSN, deliberately independent of dbx.
  • obsx/ — OpenTelemetry SDK bootstrap and the only module that links the OTel SDK: SetupObservability(ctx, ConfigFromEnv()) builds traces + metrics + logs over OTLP and returns one Shutdown; installs the logger provider in the OTel global, where logger/slogx finds it; ForceFlush exports every provider's buffer without stopping it (the facade's FATAL path); the span exporter drops the ADR-071 deny-list keys instrumentation libraries write (client.address, network.peer.*, user_agent.original) before export, under both the stock and a factory-built provider; SetupProfiling (Pyroscope). The zap bridge (ZapCore, TraceContext) ended at obsx v0.44.
  • temporalx/ — Temporal client and worker construction with the OTel tracing interceptor wired in: Dial(Config{HostPort, Namespace}), NewWorker(client, taskQueue, opts...), opt-in Worker Deployment Versioning from TEMPORAL_DEPLOYMENT_NAME + TEMPORAL_WORKER_BUILD_ID via VersioningFromEnv/MustVersioningFromEnv. Those are Temporal's own variable names — the Worker Controller injects them and Temporal's reference worker reads them — so a worker needs no hand-written identity to run under the controller. An unset versioning behaviour resolves to Pinned, matching that reference.
  • semconv/ — the platform semantic-convention registry (ADR-076): registry/ (Weaver manifest depending on upstream v1.41.0 + model/*.yaml for every platform-owned attribute, metric and event), policies/registry.rego (registered namespaces, no upstream redefinition, a UCUM unit on every metric, stability everywhere) and templates/ that generate logger/slogx/semconv_gen.go, temporalx/semconv_gen.go and the event-catalog table semconv/docs/event-catalog.md (homelab docs/api/logs.md embeds it). Not a module; make semconv-check and make semconv-generated-check gate it in CI. A name is declared here before code emits it — the local-stack gate's weaver registry live-check fails an undeclared one. See semconv/README.md.

Multi-module architecture

This is the most important thing to understand about this repo:

  • Every directory with a go.mod is an independent module. There are 12 taggable modules (counted at find . -name go.mod). logger/zapx, logger/zerolog and logger/clog were removed once the fleet moved to logger/slogx; their published tags still resolve, but they are gone from the tree and get no new releases.
  • Each module gets its own git tag in the form <module-path>/v<semver> (e.g. httpx/v0.36.0, logger/slogx/v0.2.0). Module tags continue the pre-split numbering — the last single-module tag was v0.35.0, so per-module history starts at v0.36.0. A module first published after the split starts its own history at v0.1.0 instead (httpmw, logger/slogx).
  • External consumers import specific tagged versions: go get github.com/duynhlab/pkg/httpx@v0.36.0.
  • The old root module line is dead. github.com/duynhlab/pkg is frozen at v0.35.0; a dependency graph that mixes the old require with a new per-module require fails immediately with ambiguous import (both provide the same package paths), and no newer root version will ever exist to resolve it — consumers must drop the old require entirely. Never re-create a root go.mod or put Go files at the repo root.
  • There are currently no cross-module dependencies inside this repo — every module builds standalone. Keep it that way when you can. If a genuine internal dependency ever appears, the dependent module carries a real published version in require plus a permanent sibling replace (e.g. replace github.com/duynhlab/pkg/flagx => ../flagx) for local development; replace in a non-main module is ignored by external consumers, so the require version must always be real.
  • Changing one module may require updating dependents in the services. A change to obsx.ForceFlush or to the logger provider it installs, for example, affects every service's main(), which wires it into logger/slogx.
  • Do not use go.work. With no cross-module imports there is nothing for a workspace to resolve, and a workspace file would mask module-boundary errors that CI will catch.

Dependency rules

Modules are organised in strict layers. A module may only import modules from a lower layer. Same-layer imports are forbidden even when they would not create a cycle, because they create hidden tag-ordering constraints.

Layer 0 — foundation. Zero internal dependencies.

  • proto, logger/slogx, flagx

Layer 1 — building blocks. May import Layer 0 only.

  • httpx, grpcx, authmw, idempotency

Layer 2 — terminal. Heavy third-party SDKs. No module in this repository may import a Layer 2 module.

  • obsx, dbx, migratex, temporalx

Today no module imports another at all; the layers say what is allowed if one ever must.

Concrete rules that follow from this:

  • OpenTelemetry API vs SDK. logger/*, grpcx, and dbx may import go.opentelemetry.io/otel (the API), otel/trace, otel/metric, and contrib instrumentation (otelgrpc, otelpgx). They must never import go.opentelemetry.io/otel/sdk or duynhlab/pkg/obsx. The SDK lives in obsx and nowhere else. This is what lets a service use grpcx without being forced to link the OpenTelemetry SDK. (Tests are exempt: in-memory exporters need the SDK.)
  • obsx, temporalx, and migratex are wired in main() only. Service business packages must not import them, with one narrow exception: temporalx's workflow-side helpers — Tracer for replay-safe spans, WorkflowEvent, and WorkflowFailed — are for workflow, activity and dispatcher code, because the event catalog assigns those events to temporalx and only it may write from workflow code. Construction (Dial, NewWorker, WithLogger) stays in main(). dbx is exempt from this rule — repository/store code may use it — but no module in this repo may.
  • migratex does not import dbx. It accepts a DSN string. Keeping them independent means a migration job binary does not link the pool implementation and vice versa.
  • grpcx does not import proto. Interceptors are generic. Anything that needs a concrete message type belongs in the service.
  • httpx and authmw do not import each other. Both produce gin middleware/helpers. Composition happens in the service.
  • idempotency does not import dbx. Both bind to *pgxpool.Pool directly, which keeps them independent siblings — but they must stay on compatible pgx major versions.
  • Stdlib and shared-ecosystem types at the boundary. Types appearing in an exported signature become a mandatory dependency for every consumer; types used only inside a function body do not. Prefer context.Context, error, *slog.Logger, fs.FS, *pgxpool.Pool, or a narrow interface declared locally. Never put a type from another duynhlab/pkg module into an exported signature.
  • Consumer-side interfaces. When a lower-layer module needs a capability from a higher layer, it declares the interface itself and lets main() inject the implementation, or accepts the OTel API's provider interfaces (see dbx.WithTracerProvider/WithMeterProvider).
  • Nested modules are the escape hatch. When an implementation genuinely needs a Layer 2 dependency, put it in a nested module rather than raising the parent's layer — its own go.mod, its own tag (the logger/* modules already follow this shape). A hypothetical Postgres-backed store for a Layer 1 module would live in <module>/postgres/ and may import dbx; the parent stays Layer 1.

Enforcement lives in .golangci.yml via depguard (terminal-module imports and the OTel SDK outside obsx are denied). If you need to add an internal import that the linter rejects, the import is wrong — not the linter. Escalate to a human before editing the depguard rules.

Build, test, lint

All targets in the root Makefile. Module paths use : as separator in make targets (logger/slogx → logger:slogx).

  • make modules — list the modules the Makefile discovered. Run this after adding a module to confirm it was picked up.
  • make all — runs tidy, fmt, vet, lint for all modules.
  • make test — runs the full gate for ALL modules. make test TAGS=integration additionally runs the testcontainers-backed integration tests (needs a Docker daemon).
  • make test-<module> — runs tidy, fmt, vet, lint, then go test ./... -race -coverprofile coverage.out for a single module. Examples: make test-obsx, make test-logger:slogx.
  • make tidy / make tidy-<module> — go mod tidy for all or one module.
  • make lint / make lint-<module> — golangci-lint (pinned, via go run) with the root .golangci.yml.
  • make coverage — merge per-module coverage profiles into the root coverage.out for SonarCloud.
  • make generate-proto — buf generate and buf lint.
  • make proto-breaking — buf breaking against main. Required on every PR touching proto.
  • make release-<module> VER=x.y.z — tags and pushes <module>/vx.y.z. Verifies the module exists and the tree is clean before tagging.

Targets fan out via $(MAKE) rather than a shell loop, so make -j8 test parallelises across modules.

To run a single test function, cd into the module directory and run go test ./... -run TestName -v.

Codegen and generated files

Only the proto module has codegen. buf.yaml and buf.gen.yaml live at the repo root and point at proto/. After changing any .proto file:

make generate-proto

Generated files (never hand-edit):

  • proto/**/*.pb.go
  • proto/**/*_grpc.pb.go

proto has the highest fan-in in the repo. Protobuf changes must be additive only: add fields and messages, never renumber or reuse field numbers, never rename or remove existing fields, never change a field's type. make proto-breaking enforces this and must pass before merge. Keep each option go_package at github.com/duynhlab/pkg/proto/<svc>/v1 — it is baked into the generated descriptors and is part of the module's import path.

Conventions

  • Standard gofmt. All exported names need doc comments. Match the style of the module you're editing.
  • Naming. Module directories are domain nouns. Do not create common, utils, shared, core, helpers, or internal top-level modules — they have no admission criteria and become dependency magnets. If you cannot name a module after what it does, it should not be a module yet.
  • Functional options. Constructors take New(ctx, required..., opts ...Option) (see dbx.NewPool, temporalx.NewWorker). Adding an option is additive; adding a field to an exported config struct is not always.
  • Logging. Library modules accept a *slog.Logger (or nothing); a service passes the logger/slogx facade's Slog(). Never construct a logger inside a library function and never log to a package-level default. Never log secrets, tokens, or bearer headers.
  • Configuration. Read standard environment variables rather than inventing names. obsx.ConfigFromEnv uses OTEL_SERVICE_NAME (with SERVICE_NAME fallback), OTEL_COLLECTOR_ENDPOINT, OTEL_SAMPLE_RATE, OTEL_METRICS_ENABLED, OTEL_LOGS_ENABLED, OTEL_RESOURCE_ATTRIBUTES, TRACING_ENABLED, PROFILING_ENABLED, PYROSCOPE_ENDPOINT; temporalx uses TEMPORAL_DEPLOYMENT_NAME/TEMPORAL_WORKER_BUILD_ID — Temporal's own names, not ours. It previously read an invented TEMPORAL_WORKER_DEPLOYMENT_NAME, which this rule should have caught: the Worker Controller never sets that name, so a worker reading it saw half a configuration. Do not hardcode samplers or endpoints in Go.
  • Semantic conventions. The OTel semconv version is pinned where imported (currently obsx). Bumping it renames attributes and breaks existing dashboards and alert rules — treat it as a coordinated change, not a routine dependency update.
  • Shutdown. Anything that buffers telemetry or holds connections returns a shutdown function. obsx.SetupObservability returns one; failing to call it drops the final batch of spans when a pod receives SIGTERM.
  • Cross-module test helpers. If a test in module A needs a helper from module B, duplicate the small helper. Do not add a production require on another module to satisfy a test.

Testing

  • Tests use standard go test ./... -race. The Makefile orchestrates per-module.
  • obsx and grpcx tests use the OTel SDK's in-memory exporters (tracetest.NewInMemoryExporter, sdkmetric readers) to assert on emitted telemetry. Do not assert against a live collector.
  • grpcx access-log tests assert log↔trace correlation: a request with an active span must produce a log entry carrying that span's trace ID. This is the test that catches broken context propagation, which is otherwise invisible until production.
  • dbx and idempotency integration tests use testcontainers-go with Postgres behind the integration build tag — plain make test skips them; make test TAGS=integration (or CI) runs them and needs a Docker daemon.
  • temporalx tests do not require a running Temporal server.
  • authmw failures are silent in tests that use a permissive stub. When adding a claims field, add a negative test asserting rejection, not only a positive test asserting acceptance.
  • Match the module's existing test framework (stdlib testing, table-driven). Do not introduce a new assertion library.

Gotchas and non-obvious rules

  • No top-level go.mod. From the repo root, go build ./... and go test ./... fail with "go.mod file not found" — nothing at the root builds or tests the 12 modules. Always work within a module directory or use make test-<module>.
  • A new minor of a telemetry module moves the fleet floor. obsx, logger/slogx, httpmw, grpcx and temporalx have a version floor in the fleet lint policy (duynhlab/gha-workflows .github/lint/golangci-policy.yml, ADR-072): a service more than one minor behind fails its lint. Bump the floor to the previous minor in the same change that announces the release and say so in the release notes.
  • Module versioning is independent. Changing logger/slogx does not bump httpx. Tag each changed module separately at release time.
  • Tag order matters once modules depend on each other. Tag dependencies before dependents (Layer 0 → 1 → 2), otherwise a require line points at a tag that does not exist yet and external go get fails even though local builds pass.
  • A pushed tag cannot be fixed. The Go module proxy caches immediately. A wrong obsx/v0.36.0 cannot be corrected — you must burn the version and publish v0.36.1. make release-<module> checks the module exists and the tree is clean, but it cannot check that the content is right.
  • Adding a new exported symbol is a cross-repo contract change. All platform services depend on these modules. Renaming, removing, or changing the signature of any exported type or function breaks downstream consumers even if this repo's tests pass.
  • Generated files are never edited by hand. logger/slogx/semconv_gen.go, temporalx/semconv_gen.go and semconv/docs/event-catalog.md come from make semconv-generate; change semconv/registry/model/ and regenerate, in the same commit — CI diffs them.
  • Adding a new module requires: go mod init github.com/duynhlab/pkg/<name>, adding it to the Repository layout and Dependency rules sections above, and adding it to README.md. The Makefile discovers it automatically — confirm with make modules.
  • The Makefile computes MODULES dynamically by scanning for go.mod files, bounded by -maxdepth 4. A module nested deeper than that bound disappears from every target with no error. Run make modules after adding one and confirm the count.
  • Colon encoding in make targets. logger/slogx is targeted as make test-logger:slogx. The Makefile translates : back to / internally.
  • flagx is startup-time by design. It reads and validates env vars once, at process start, and fails fast on invalid values. Do not use it for per-request or runtime-mutable flags — that is a different tool.
  • Naming is inconsistent by history. Some modules carry an x suffix (httpx, grpcx, dbx, obsx, flagx, migratex, temporalx, logger/slogx) and some do not (proto, authmw, idempotency). Do not rename existing modules — the import path is a published contract. New modules follow the x suffix convention.