From d0b7e4d2984f847988196d0bef647ff66093e0a4 Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:43:36 -0400 Subject: [PATCH 01/20] feat(accounts,auth-gateway): resolve the cell's collector from the observability group (Refs #1022) accounts and auth-gateway used to reach an in-graph OTLP gateway through `Service("telemetry")`, with the service name typed as a Go literal and a constant `WithInsecure()`. They now read where the cell's collector is from the `observability` configuration group, which the platform delivers as `TELEMETRY_STATE` (`available` or `absent`, never empty), with `OTEL_EXPORTER_OTLP_ENDPOINT` when available and `TELEMETRY_ABSENT_REASON` when absent. The group's three keys resolve to one of three outcomes: - available with an endpoint: traces and the OTLP metric push go to it, and the transport is derived from the endpoint's scheme (`http://` is plaintext because the mesh supplies mTLS; `https://` takes the exporter's TLS default). - absent with a reason: no OTLP exporters, the reason is logged once, boot continues. A local run uses wool's own stdout tracer for traces. - anything else (state missing or unknown, available without an endpoint, absent without a reason, both set, a malformed endpoint): refuse to start, naming what is wrong. A group that did not arrive is not a cell without a collector, and reading it as one would leave a deployment converging green while exporting nothing. accounts checks this before it touches the database or Vault. The group is read only through the SDK's workspace lookup, with no fallback to a bare process variable: an ambient OTEL_EXPORTER_OTLP_ENDPOINT is conventionally the OTLP/HTTP address, not the cell's gRPC collector. Metrics now leave by one path, OTLP push. The Prometheus scrape exporter, its /metrics mount on both services and the accounts gateway-route exemption that existed only for it are removed, with their dependencies. service.name is no longer a literal. OTEL_SERVICE_NAME or OTEL_RESOURCE_ATTRIBUTES wins, and otherwise the service identity the SDK's provider holds (the one wool stamps on log lines) is used, read from the provider rather than the runtime's environment carrier. wool's tracer cannot dial TLS: wool/otel.Enable always sets an insecure transport and has no option to turn it off. An https:// endpoint is therefore refused at startup instead of being dialed in the clear and reported as TLS, until wool can dial it. This is a stopgap in this repository; the fix belongs in wool. The resolution is two small mirrored copies, one per service, because they are independent Go modules with no shared package; a module/tools test holds the copies byte-identical. `configurations/local/observability.env` and the local-dogfood example now say `TELEMETRY_STATE=absent` with a reason, and OBSERVABILITY_EXPORTER is gone. The dogfood secret example held only the collector's external headers, which nothing reads any more, so it is removed with its ignore entry. Tested: unit tests for the three outcomes in both services, including a loopback OTLP receiver that checks exported spans and metrics carry the resolved service.name. The real auth-gateway binary refuses with no group or an unknown state, and passes the telemetry step with `absent` (then stops at the unrelated missing runtime endpoint, since no Codefly runtime was present). Not exercised against a real cell collector. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 - .../local-dogfood/observability.env.example | 24 +- .../observability.secret.env.example | 3 - module/configurations/local/observability.env | 19 +- module/services/accounts/code/go.mod | 9 +- module/services/accounts/code/go.sum | 18 - .../accounts/code/telemetry_destination.go | 267 ++++++++++++ .../code/telemetry_destination_test.go | 397 ++++++++++++++++++ .../accounts/code/telemetry_metrics.go | 49 +-- .../accounts/code/telemetry_metrics_test.go | 22 +- module/services/accounts/code/work.go | 80 ++-- .../services/accounts/code/work_auth_test.go | 19 - .../auth-gateway/code/configuration.go | 5 - .../auth-gateway/code/configuration_test.go | 19 - module/services/auth-gateway/code/go.mod | 9 +- module/services/auth-gateway/code/go.sum | 20 - module/services/auth-gateway/code/main.go | 37 +- .../code/telemetry_destination.go | 267 ++++++++++++ .../code/telemetry_destination_test.go | 376 +++++++++++++++++ .../auth-gateway/code/telemetry_metrics.go | 56 +-- .../code/telemetry_metrics_test.go | 33 +- .../telemetry_destination_mirror_test.go | 40 ++ 22 files changed, 1489 insertions(+), 281 deletions(-) delete mode 100644 configurations/local-dogfood/observability.secret.env.example create mode 100644 module/services/accounts/code/telemetry_destination.go create mode 100644 module/services/accounts/code/telemetry_destination_test.go create mode 100644 module/services/auth-gateway/code/telemetry_destination.go create mode 100644 module/services/auth-gateway/code/telemetry_destination_test.go create mode 100644 module/tools/telemetry_destination_mirror_test.go diff --git a/.gitignore b/.gitignore index f49d97580..ae9ebe8da 100644 --- a/.gitignore +++ b/.gitignore @@ -46,7 +46,6 @@ module/services/*/nix/ /configurations/local-dogfood/error-tracking.env /configurations/local-dogfood/error-tracking.secret.env /configurations/local-dogfood/observability.env -/configurations/local-dogfood/observability.secret.env /configurations/local-dogfood/abuse-protection.env /configurations/local-dogfood/abuse-protection.secret.env /configurations/local-dogfood/legal.env diff --git a/configurations/local-dogfood/observability.env.example b/configurations/local-dogfood/observability.env.example index 2ad133ad0..73ad6b219 100644 --- a/configurations/local-dogfood/observability.env.example +++ b/configurations/local-dogfood/observability.env.example @@ -1,6 +1,18 @@ -# Copy to observability.env and fill it in. SigNoz is the -# designated OTLP backend; this remains application instrumentation config. -# "debug" keeps telemetry local; "otlphttp" forwards through the collector. -OBSERVABILITY_EXPORTER=debug -OTEL_EXPORTER_OTLP_ENDPOINT= -OTEL_EXPORTER_OTLP_HEADERS= +# Copy to observability.env and fill it in. This module owns no collector: the +# cell's collector is where traces and metrics go, and the platform says where it is. +# +# A dogfood run on a laptop is not a cell, so by default it has none. The default +# below is what a plain local run uses: wool's stdout tracer stands in for traces +# and metrics are not exported. +TELEMETRY_STATE=absent +TELEMETRY_ABSENT_REASON=A local dogfood run has no cell collector; traces go to the stdout tracer and metrics are not exported. + +# To export to a collector this machine can reach (for example a cell's, through a +# port-forward), replace the two lines above with: +# +# TELEMETRY_STATE=available +# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +# +# The endpoint is the collector's OTLP/gRPC address. http:// is plaintext, which +# only belongs on loopback or inside the mesh; https:// is TLS. Set the endpoint +# when the state is available and the reason when it is absent, never both. diff --git a/configurations/local-dogfood/observability.secret.env.example b/configurations/local-dogfood/observability.secret.env.example deleted file mode 100644 index 87476f787..000000000 --- a/configurations/local-dogfood/observability.secret.env.example +++ /dev/null @@ -1,3 +0,0 @@ -# Copy to observability.secret.env. This file is ignored by Git. -# Example: Authorization=Basic BASE64_INSTANCE_ID_API_TOKEN -OTEL_EXPORTER_OTLP_HEADERS= diff --git a/module/configurations/local/observability.env b/module/configurations/local/observability.env index de82d1d00..ed8a6da2c 100644 --- a/module/configurations/local/observability.env +++ b/module/configurations/local/observability.env @@ -1,3 +1,16 @@ -OBSERVABILITY_EXPORTER=debug -OTEL_EXPORTER_OTLP_ENDPOINT= -OTEL_EXPORTER_OTLP_HEADERS= +# Where accounts and auth-gateway send traces and metrics, or that they send none. +# The platform delivers exactly one of two answers, and a process that gets neither +# refuses to start rather than guess: +# +# TELEMETRY_STATE=available the cell has a collector. Also sets +# OTEL_EXPORTER_OTLP_ENDPOINT to its OTLP/gRPC address, +# e.g. http://otel-collector.otel-collector.svc.cluster.local:4317 +# (http:// is plaintext because the mesh supplies mTLS; +# https:// is TLS). +# TELEMETRY_STATE=absent the cell has none. Also sets TELEMETRY_ABSENT_REASON to +# say why. +# +# A local run has no cell, so it has no collector: traces go to wool's stdout tracer +# and metrics are not exported. Logs always go to stdout. +TELEMETRY_STATE=absent +TELEMETRY_ABSENT_REASON=A local run has no cell collector; traces go to the stdout tracer and metrics are not exported. diff --git a/module/services/accounts/code/go.mod b/module/services/accounts/code/go.mod index 1baf6311e..a8374adaf 100644 --- a/module/services/accounts/code/go.mod +++ b/module/services/accounts/code/go.mod @@ -19,7 +19,6 @@ require ( github.com/google/uuid v1.6.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 github.com/jackc/pgx/v5 v5.10.0 - github.com/prometheus/client_golang v1.24.1 github.com/redis/go-redis/v9 v9.22.0 github.com/rs/cors v1.11.1 github.com/stretchr/testify v1.12.1 @@ -27,7 +26,6 @@ require ( go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0 go.opentelemetry.io/otel v1.46.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 - go.opentelemetry.io/otel/exporters/prometheus v0.68.0 go.opentelemetry.io/otel/metric v1.46.0 go.opentelemetry.io/otel/sdk v1.46.0 go.opentelemetry.io/otel/sdk/metric v1.46.0 @@ -54,7 +52,6 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/VividCortex/ewma v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -98,16 +95,11 @@ require ( github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.28 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect - github.com/prometheus/client_model v0.6.3 // indirect - github.com/prometheus/common v0.71.0 // indirect - github.com/prometheus/otlptranslator v1.0.0 // indirect - github.com/prometheus/procfs v0.22.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.2.2 // indirect @@ -124,6 +116,7 @@ require ( go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 // indirect go.opentelemetry.io/otel/trace v1.46.0 // indirect go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect golang.org/x/term v0.46.0 // indirect diff --git a/module/services/accounts/code/go.sum b/module/services/accounts/code/go.sum index 8addb8a9b..bb1012c57 100644 --- a/module/services/accounts/code/go.sum +++ b/module/services/accounts/code/go.sum @@ -36,8 +36,6 @@ github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1o github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -216,8 +214,6 @@ github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRg github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -242,8 +238,6 @@ github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -262,16 +256,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU= github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= -github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= -github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= -github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= -github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8= -github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk= -github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= -github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= -github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= -github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= @@ -333,8 +317,6 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzb go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY= -go.opentelemetry.io/otel/exporters/prometheus v0.68.0 h1:QOf2IftqQwITVRJpnn0M7M9ZCbgWfxz4P7i9C9yc2N4= -go.opentelemetry.io/otel/exporters/prometheus v0.68.0/go.mod h1:bgSvqu2TWGXiz7yr5UTMfObH8oqxJWHTnubQ3ef9BO4= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0/go.mod h1:K/qSA+3G7Eovxi4K09wzrAgkWRnosS0DAOZeEpve7sM= go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= diff --git a/module/services/accounts/code/telemetry_destination.go b/module/services/accounts/code/telemetry_destination.go new file mode 100644 index 000000000..e3296805d --- /dev/null +++ b/module/services/accounts/code/telemetry_destination.go @@ -0,0 +1,267 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "strings" + + "github.com/codefly-dev/core/wool" + wooltel "github.com/codefly-dev/core/wool/otel" + codefly "github.com/codefly-dev/sdk-go" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/sdk/resource" +) + +// Where this process sends traces and metrics — or that it sends none. +// +// The platform tells a workload through the `observability` configuration +// group, and the group always says one of two things: +// +// TELEMETRY_STATE=available with OTEL_EXPORTER_OTLP_ENDPOINT, the cell's +// collector (OTLP/gRPC), e.g. +// http://otel-collector.otel-collector.svc.cluster.local:4317 +// TELEMETRY_STATE=absent with TELEMETRY_ABSENT_REASON, why the cell has no +// collector +// +// `http://` is plaintext on the wire because the service mesh supplies mTLS +// between workloads; `https://` is TLS, which the metrics exporter dials but +// wool's tracer cannot yet (see errWoolCannotDialTLS). This module owns no +// collector: the cell does, and a workload that cannot find it does not +// substitute one. +// +// A group that did not arrive is not a cell without a backend. Reading the first +// as the second would leave a deployment that converges green and exports +// nothing, so anything that is not exactly one of the two answers above refuses +// to start, naming what is missing. +// +// This file exists byte for byte in services/accounts/code and +// services/auth-gateway/code: the two services are independent Go modules with +// no shared package, and module/tools/telemetry_destination_mirror_test.go holds +// the copies identical. Change one, copy it over the other. + +const ( + telemetryStateKey = "TELEMETRY_STATE" + telemetryEndpointKey = "OTEL_EXPORTER_OTLP_ENDPOINT" + telemetryAbsentReasonKey = "TELEMETRY_ABSENT_REASON" + + telemetryStateAvailable = "available" + telemetryStateAbsent = "absent" + + // maxAbsentReasonLength bounds the operator-written reason before it reaches + // a log line: an unbounded value from configuration would otherwise let + // whoever writes the group forge log entries around it. + maxAbsentReasonLength = 300 +) + +// telemetryDestination is the resolved answer. Exactly one of Endpoint and +// AbsentReason is set. +type telemetryDestination struct { + // Endpoint is the collector's OTLP/gRPC address as host:port, the form the + // exporters take. Empty when the cell has no collector. + Endpoint string + // Insecure is true when the endpoint was http://, which the mesh protects. + // It is derived from the endpoint's scheme and never assumed. + Insecure bool + // AbsentReason says why the cell has no collector. Empty when Endpoint is set. + AbsentReason string +} + +// Available reports whether the cell has a collector to export to. +func (d telemetryDestination) Available() bool { + return d.Endpoint != "" +} + +// observabilityValue reads one key of the `observability` group through the +// SDK. It deliberately has no fallback to a bare process variable: an ambient +// OTEL_EXPORTER_OTLP_ENDPOINT in a shell or a pod is conventionally the +// OTLP/HTTP address, not the cell's gRPC collector, and reading it here would +// export to the wrong place without a word. +func observabilityValue(key string) string { + value, err := codefly.For(codefly.Context()).WorkspaceValue("observability", key) + if err != nil { + return "" + } + return value +} + +// configuredTelemetryDestination resolves the destination from the +// `observability` group. +func configuredTelemetryDestination() (telemetryDestination, error) { + return resolveTelemetryDestination(observabilityValue) +} + +// resolveTelemetryDestination is the whole decision, over any reader of the +// group's keys so a test can reach every outcome with nothing running. +func resolveTelemetryDestination(read func(key string) string) (telemetryDestination, error) { + state := strings.TrimSpace(read(telemetryStateKey)) + endpoint := strings.TrimSpace(read(telemetryEndpointKey)) + reason := boundedLine(read(telemetryAbsentReasonKey), maxAbsentReasonLength) + + if state == "" { + return telemetryDestination{}, errors.New( + "observability: " + telemetryStateKey + " is not set in the `observability` configuration group. " + + "The platform delivers `" + telemetryStateAvailable + "` (with " + telemetryEndpointKey + ") or `" + + telemetryStateAbsent + "` (with " + telemetryAbsentReasonKey + "); a group that did not arrive is " + + "not a cell without a collector, so this process does not start without it") + } + if state != telemetryStateAvailable && state != telemetryStateAbsent { + return telemetryDestination{}, fmt.Errorf( + "observability: %s is %q in the `observability` configuration group; it must be `%s` or `%s`", + telemetryStateKey, state, telemetryStateAvailable, telemetryStateAbsent) + } + if endpoint != "" && reason != "" { + return telemetryDestination{}, fmt.Errorf( + "observability: the `observability` configuration group sets both %s and %s; "+ + "a cell either has a collector or says why it has none", + telemetryEndpointKey, telemetryAbsentReasonKey) + } + + if state == telemetryStateAbsent { + if reason == "" { + return telemetryDestination{}, fmt.Errorf( + "observability: %s is `%s` but %s is not set in the `observability` configuration group; "+ + "an absent collector must say why", + telemetryStateKey, telemetryStateAbsent, telemetryAbsentReasonKey) + } + return telemetryDestination{AbsentReason: reason}, nil + } + + if endpoint == "" { + return telemetryDestination{}, fmt.Errorf( + "observability: %s is `%s` but %s is not set in the `observability` configuration group", + telemetryStateKey, telemetryStateAvailable, telemetryEndpointKey) + } + hostPort, insecure, err := parseCollectorEndpoint(endpoint) + if err != nil { + return telemetryDestination{}, fmt.Errorf("observability: %s: %w", telemetryEndpointKey, err) + } + return telemetryDestination{Endpoint: hostPort, Insecure: insecure}, nil +} + +// parseCollectorEndpoint turns the delivered URL into the host:port the OTLP +// gRPC exporters take, and reads the transport off its scheme. The URL is never +// echoed back: a malformed one may carry credentials. +func parseCollectorEndpoint(raw string) (hostPort string, insecure bool, err error) { + parsed, parseErr := url.Parse(raw) + if parseErr != nil { + return "", false, errors.New("is not a URL; expected http://host:port or https://host:port") + } + switch parsed.Scheme { + case "http": + insecure = true + case "https": + default: + return "", false, fmt.Errorf("must start with http:// or https://, got scheme %q", parsed.Scheme) + } + if parsed.Hostname() == "" { + return "", false, errors.New("has no host") + } + if parsed.User != nil { + return "", false, errors.New("must not carry credentials") + } + // OTLP/gRPC addresses a collector, not a path: a path or a query is the + // OTLP/HTTP form (/v1/traces), which this exporter does not speak. + if (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", false, errors.New("must be the collector's origin only, with no path, query or fragment") + } + port := parsed.Port() + if port == "" { + port = "80" + if !insecure { + port = "443" + } + } + return net.JoinHostPort(parsed.Hostname(), port), insecure, nil +} + +// boundedLine collapses a free-text value to one bounded line. +func boundedLine(value string, limit int) string { + line := strings.Join(strings.Fields(value), " ") + if runes := []rune(line); len(runes) > limit { + return string(runes[:limit]) + "…" + } + return line +} + +// telemetryResource is the one resource both signals carry. service.name is +// whatever the environment says — OTEL_SERVICE_NAME, or service.name in +// OTEL_RESOURCE_ATTRIBUTES — and otherwise the service identity the Codefly SDK +// holds. No name is typed here: a literal would override the environment, so the +// platform's identity could never reach the backend. +func telemetryResource(ctx context.Context, identity string) (*resource.Resource, error) { + options := []resource.Option{resource.WithService()} + if identity != "" { + options = append(options, resource.WithAttributes(attribute.String("service.name", identity))) + } + // Last, so what the environment declares wins over the identity above. + options = append(options, resource.WithFromEnv()) + return resource.New(ctx, options...) +} + +// currentTelemetryResource is telemetryResource for this process. +func currentTelemetryResource(ctx context.Context) (*resource.Resource, error) { + return telemetryResource(ctx, codeflyServiceName(codefly.Context())) +} + +// codeflyServiceName is the service identity the Codefly SDK holds: the +// `module/service` identity of the provider it built at Init, which wool stamps on +// every log line from this process. It is empty when the SDK has not been +// initialised. The SDK exposes no accessor of its own, and the environment +// carrier it reads this from is not product code's to read, so this asks the +// provider. +func codeflyServiceName(ctx context.Context) string { + provider, ok := ctx.Value(wool.ProviderKey).(*wool.Provider) + if !ok || provider == nil { + return "" + } + source := provider.Get(ctx).Source() + if source == nil { + return "" + } + // An identity with no module reads `/service`; with neither, `/`. + return strings.Trim(strings.TrimSpace(source.Unique), "/") +} + +// telemetryServiceName is the service.name the resource resolved to. +func telemetryServiceName(res *resource.Resource) string { + if value, ok := res.Set().Value(attribute.Key("service.name")); ok { + return value.AsString() + } + return "" +} + +// errWoolCannotDialTLS names the one transport the tracer cannot serve. +var errWoolCannotDialTLS = errors.New( + "the collector endpoint is https://, but wool's OTLP tracer cannot dial TLS: wool/otel.Enable " + + "always sets an insecure transport and offers no option to turn it off. Deliver an http:// " + + "endpoint (in-mesh, where the mesh supplies mTLS) until wool can dial TLS") + +// enableTracing registers wool's tracer for the destination: OTLP/gRPC to the +// cell's collector when there is one; wool's own stdout tracer when there is +// none and this is a local run; nothing otherwise. A nil provider means nothing +// was registered. +func enableTracing(ctx context.Context, destination telemetryDestination, local bool) (*wooltel.Provider, error) { + res, err := currentTelemetryResource(ctx) + if err != nil { + return nil, fmt.Errorf("resolve telemetry resource: %w", err) + } + options := []wooltel.Option{wooltel.WithServiceName(telemetryServiceName(res))} + switch { + case destination.Available(): + // wool dials plaintext unless told otherwise and cannot be told + // otherwise, so an https:// endpoint is refused rather than dialed in + // the clear and reported as TLS. + if !destination.Insecure { + return nil, errWoolCannotDialTLS + } + options = append(options, wooltel.WithEndpoint(destination.Endpoint), wooltel.WithInsecure()) + case local: + options = append(options, wooltel.WithStdout()) + default: + return nil, nil + } + return wooltel.Enable(options...) +} diff --git a/module/services/accounts/code/telemetry_destination_test.go b/module/services/accounts/code/telemetry_destination_test.go new file mode 100644 index 000000000..c2d1e4d24 --- /dev/null +++ b/module/services/accounts/code/telemetry_destination_test.go @@ -0,0 +1,397 @@ +package main + +import ( + "context" + "net" + "strings" + "testing" + "time" + + "github.com/codefly-dev/core/wool" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + collectortracev1 "go.opentelemetry.io/proto/otlp/collector/trace/v1" + "google.golang.org/grpc" +) + +// group is a reader over a fixed set of `observability` keys. +func group(values map[string]string) func(string) string { + return func(key string) string { return values[key] } +} + +const collector = "http://otel-collector.otel-collector.svc.cluster.local:4317" + +// The first outcome: the platform delivered a collector. +func TestTelemetryDestinationAvailableExportsToTheEndpoint(t *testing.T) { + destination, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "available", + "OTEL_EXPORTER_OTLP_ENDPOINT": collector, + })) + require.NoError(t, err) + require.True(t, destination.Available()) + require.Equal(t, "otel-collector.otel-collector.svc.cluster.local:4317", destination.Endpoint) + require.True(t, destination.Insecure, "http:// is plaintext on the wire; the mesh supplies mTLS") + require.Empty(t, destination.AbsentReason) +} + +// The transport comes from the endpoint's scheme, never from a constant. +func TestTelemetryDestinationReadsTransportFromTheScheme(t *testing.T) { + for endpoint, want := range map[string]struct { + hostPort string + insecure bool + }{ + "http://collector.example:4317": {"collector.example:4317", true}, + "https://collector.example:4317": {"collector.example:4317", false}, + "https://collector.example": {"collector.example:443", false}, + "http://collector.example": {"collector.example:80", true}, + "http://collector.example:4317/": {"collector.example:4317", true}, + " http://collector.example:4317 ": {"collector.example:4317", true}, + "http://[2001:db8::1]:4317": {"[2001:db8::1]:4317", true}, + "https://10.0.0.7:4317": {"10.0.0.7:4317", false}, + "http://otel-collector.ns.svc:4317/": {"otel-collector.ns.svc:4317", true}, + } { + destination, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "available", + "OTEL_EXPORTER_OTLP_ENDPOINT": endpoint, + })) + require.NoError(t, err, endpoint) + require.Equal(t, want.hostPort, destination.Endpoint, endpoint) + require.Equal(t, want.insecure, destination.Insecure, endpoint) + } +} + +// The second outcome: the cell has no collector, and says why. The process boots +// and exports nothing. +func TestTelemetryDestinationAbsentCarriesTheReason(t *testing.T) { + destination, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "absent", + "TELEMETRY_ABSENT_REASON": "This cell's bucket has no ops cell, so no collector is rendered.", + })) + require.NoError(t, err) + require.False(t, destination.Available()) + require.Empty(t, destination.Endpoint) + require.Equal(t, "This cell's bucket has no ops cell, so no collector is rendered.", destination.AbsentReason) +} + +// The reason is operator-written free text that reaches a log line. +func TestTelemetryDestinationAbsentReasonIsOneBoundedLine(t *testing.T) { + destination, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "absent", + "TELEMETRY_ABSENT_REASON": "no collector\n2026-01-01 FORGED entry\r\n" + strings.Repeat("x", 1000), + })) + require.NoError(t, err) + require.NotContains(t, destination.AbsentReason, "\n") + require.NotContains(t, destination.AbsentReason, "\r") + require.LessOrEqual(t, len([]rune(destination.AbsentReason)), maxAbsentReasonLength+1) +} + +// The third outcome: anything else refuses to start, naming what is wrong. +// Configuration that did not arrive is never read as "no collector". +func TestTelemetryDestinationRefusesEverythingElse(t *testing.T) { + for name, tc := range map[string]struct { + values map[string]string + want string + }{ + "state missing": { + values: map[string]string{}, + want: "TELEMETRY_STATE is not set", + }, + "state missing though an ambient endpoint is present": { + values: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": collector}, + want: "TELEMETRY_STATE is not set", + }, + "state missing though a reason is present": { + values: map[string]string{"TELEMETRY_ABSENT_REASON": "no collector"}, + want: "TELEMETRY_STATE is not set", + }, + "state blank": { + values: map[string]string{"TELEMETRY_STATE": " "}, + want: "TELEMETRY_STATE is not set", + }, + "state unknown": { + values: map[string]string{"TELEMETRY_STATE": "disabled", "TELEMETRY_ABSENT_REASON": "no collector"}, + want: `TELEMETRY_STATE is "disabled"`, + }, + "state is not matched case-insensitively": { + values: map[string]string{"TELEMETRY_STATE": "Available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, + want: `TELEMETRY_STATE is "Available"`, + }, + "available without an endpoint": { + values: map[string]string{"TELEMETRY_STATE": "available"}, + want: "OTEL_EXPORTER_OTLP_ENDPOINT is not set", + }, + "available with only a reason": { + values: map[string]string{"TELEMETRY_STATE": "available", "TELEMETRY_ABSENT_REASON": "no collector"}, + want: "OTEL_EXPORTER_OTLP_ENDPOINT is not set", + }, + "absent without a reason": { + values: map[string]string{"TELEMETRY_STATE": "absent"}, + want: "TELEMETRY_ABSENT_REASON is not set", + }, + "absent with a blank reason": { + values: map[string]string{"TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": " \n "}, + want: "TELEMETRY_ABSENT_REASON is not set", + }, + "absent with only an endpoint": { + values: map[string]string{"TELEMETRY_STATE": "absent", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, + want: "TELEMETRY_ABSENT_REASON is not set", + }, + "available with both an endpoint and a reason": { + values: map[string]string{ + "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, + "TELEMETRY_ABSENT_REASON": "no collector", + }, + want: "sets both OTEL_EXPORTER_OTLP_ENDPOINT and TELEMETRY_ABSENT_REASON", + }, + "absent with both an endpoint and a reason": { + values: map[string]string{ + "TELEMETRY_STATE": "absent", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, + "TELEMETRY_ABSENT_REASON": "no collector", + }, + want: "sets both OTEL_EXPORTER_OTLP_ENDPOINT and TELEMETRY_ABSENT_REASON", + }, + "endpoint without a scheme": { + values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "otel-collector.ns.svc:4317"}, + want: "must start with http:// or https://", + }, + "endpoint with another scheme": { + values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "grpc://otel-collector.ns.svc:4317"}, + want: `got scheme "grpc"`, + }, + "endpoint without a host": { + values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://:4317"}, + want: "has no host", + }, + "endpoint naming an OTLP/HTTP path": { + values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector.example:4318/v1/traces"}, + want: "origin only", + }, + "endpoint with a query": { + values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector.example:4317?x=1"}, + want: "origin only", + }, + } { + t.Run(name, func(t *testing.T) { + destination, err := resolveTelemetryDestination(group(tc.values)) + require.Error(t, err) + require.ErrorContains(t, err, tc.want) + require.False(t, destination.Available(), "a refusal must never read as a usable destination") + require.Empty(t, destination.AbsentReason, "a refusal must never read as an absent collector") + }) + } +} + +// A refusal quotes no part of an endpoint, which may carry credentials. +func TestTelemetryDestinationRefusalDoesNotEchoCredentials(t *testing.T) { + _, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "available", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://user:hunter2@collector.example:4317", + })) + require.ErrorContains(t, err, "must not carry credentials") + require.NotContains(t, err.Error(), "hunter2") +} + +// The group is read through the SDK's workspace-configuration lookup, by the +// names the platform delivers them under. A bare process variable of the same +// name is not the cell's answer. +func TestConfiguredTelemetryDestinationReadsTheObservabilityGroup(t *testing.T) { + const prefix = "CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__" + for _, key := range []string{"TELEMETRY_STATE", "OTEL_EXPORTER_OTLP_ENDPOINT", "TELEMETRY_ABSENT_REASON"} { + t.Setenv(key, "") + t.Setenv(prefix+key, "") + } + + _, err := configuredTelemetryDestination() + require.ErrorContains(t, err, "TELEMETRY_STATE is not set") + + // Ambient variables a shell or a pod may carry are not the group. + t.Setenv("TELEMETRY_STATE", "available") + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://ambient.example:4318") + _, err = configuredTelemetryDestination() + require.ErrorContains(t, err, "TELEMETRY_STATE is not set") + t.Setenv("TELEMETRY_STATE", "") + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "") + + t.Setenv(prefix+"TELEMETRY_STATE", "available") + t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", collector) + destination, err := configuredTelemetryDestination() + require.NoError(t, err) + require.Equal(t, "otel-collector.otel-collector.svc.cluster.local:4317", destination.Endpoint) + require.True(t, destination.Insecure) + + t.Setenv(prefix+"TELEMETRY_STATE", "absent") + t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", "") + t.Setenv(prefix+"TELEMETRY_ABSENT_REASON", "a local run has no cell collector") + destination, err = configuredTelemetryDestination() + require.NoError(t, err) + require.False(t, destination.Available()) + require.Equal(t, "a local run has no cell collector", destination.AbsentReason) +} + +// Configuration that cannot work is refused before the process acquires +// anything, including in a local run: a group that did not arrive is not a cell +// without a collector anywhere. +func TestStartupRefusesAnObservabilityGroupThatDidNotArrive(t *testing.T) { + const prefix = "CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__" + for _, key := range []string{"TELEMETRY_STATE", "OTEL_EXPORTER_OTLP_ENDPOINT", "TELEMETRY_ABSENT_REASON"} { + t.Setenv(prefix+key, "") + } + // The key-custody requirement answers first outside a local run; satisfy it + // so the observability requirement is what is under test. + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__VAULT__VAULT_KEY_CUSTODY", "seed-signing-key") + for _, local := range []bool{true, false} { + require.ErrorContains(t, requireStartupConfiguration(local), "TELEMETRY_STATE is not set", "local=%v", local) + } + + t.Setenv(prefix+"TELEMETRY_STATE", "absent") + t.Setenv(prefix+"TELEMETRY_ABSENT_REASON", "a local run has no cell collector") + require.NoError(t, requireStartupConfiguration(true)) + require.NoError(t, requireStartupConfiguration(false)) +} + +// service.name is never a literal typed here: the environment wins, then the +// service's own Codefly identity. +func TestTelemetryResourceServiceName(t *testing.T) { + resolve := func(t *testing.T, identity string) string { + t.Helper() + res, err := telemetryResource(t.Context(), identity) + require.NoError(t, err) + return telemetryServiceName(res) + } + + t.Run("falls back to the Codefly service identity", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + require.Equal(t, "saas-starter/accounts", resolve(t, "saas-starter/accounts")) + }) + t.Run("OTEL_SERVICE_NAME wins over the identity", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + require.Equal(t, "named-by-the-platform", resolve(t, "saas-starter/accounts")) + }) + t.Run("OTEL_RESOURCE_ATTRIBUTES can set it", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.name=attributed-by-the-platform,deployment.environment=test") + require.Equal(t, "attributed-by-the-platform", resolve(t, "saas-starter/accounts")) + }) + t.Run("without either, the SDK default rather than a literal", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + require.True(t, strings.HasPrefix(resolve(t, ""), "unknown_service"), resolve(t, "")) + }) +} + +// The identity is the one the SDK built its provider with, the same one wool +// stamps on this process's log lines — read from the provider, not from the +// runtime's environment carrier. +func TestCodeflyServiceNameIsTheSDKProviderIdentity(t *testing.T) { + carrying := func(unique string) context.Context { + return wool.New(t.Context(), &wool.Resource{Kind: "service", Unique: unique}).Inject(t.Context()) + } + require.Equal(t, "saas-starter/accounts", codeflyServiceName(carrying("saas-starter/accounts"))) + require.Equal(t, "accounts", codeflyServiceName(carrying("/accounts")), "an identity with no module") + require.Empty(t, codeflyServiceName(carrying("/")), "an identity with neither") + require.Empty(t, codeflyServiceName(t.Context()), "no provider: the SDK was not initialised") +} + +// wool's tracer speaks plaintext only. An https:// endpoint is refused rather +// than dialed in the clear and reported as TLS. +func TestEnableTracingRefusesATransportWoolCannotDial(t *testing.T) { + provider, err := enableTracing(t.Context(), + telemetryDestination{Endpoint: "collector.example:4317", Insecure: false}, false) + require.ErrorIs(t, err, errWoolCannotDialTLS) + require.Nil(t, provider) +} + +func TestEnableTracingRegistersOnlyWhatTheDestinationCalls(t *testing.T) { + restore := func(t *testing.T) { + t.Helper() + previous := otel.GetTracerProvider() + t.Cleanup(func() { + wool.RegisterTelemetry(nil) + otel.SetTracerProvider(previous) + }) + } + absent := telemetryDestination{AbsentReason: "a local run has no cell collector"} + + t.Run("absent outside a local run registers no tracer", func(t *testing.T) { + restore(t) + provider, err := enableTracing(t.Context(), absent, false) + require.NoError(t, err) + require.Nil(t, provider) + require.False(t, wool.TelemetryEnabled()) + }) + t.Run("absent in a local run uses wool's stdout tracer", func(t *testing.T) { + restore(t) + provider, err := enableTracing(t.Context(), absent, true) + require.NoError(t, err) + require.NotNil(t, provider) + require.True(t, wool.TelemetryEnabled()) + shutdown(t, provider) + }) + t.Run("available over http exports spans to the collector, named by the environment", func(t *testing.T) { + restore(t) + t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + endpoint, capture := startTraceCapture(t) + provider, err := enableTracing(t.Context(), telemetryDestination{Endpoint: endpoint, Insecure: true}, false) + require.NoError(t, err) + require.NotNil(t, provider) + require.True(t, wool.TelemetryEnabled()) + + _, span := otel.Tracer("test").Start(t.Context(), "operation") + span.End() + shutdown(t, provider) // flushes the batch + + select { + case request := <-capture.requests: + var name string + for _, resourceSpans := range request.GetResourceSpans() { + for _, attribute := range resourceSpans.GetResource().GetAttributes() { + if attribute.GetKey() == "service.name" { + name = attribute.GetValue().GetStringValue() + } + } + } + require.Equal(t, "named-by-the-platform", name) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for OTLP spans") + } + }) +} + +type traceCapture struct { + collectortracev1.UnimplementedTraceServiceServer + requests chan *collectortracev1.ExportTraceServiceRequest +} + +func (c *traceCapture) Export( + _ context.Context, + request *collectortracev1.ExportTraceServiceRequest, +) (*collectortracev1.ExportTraceServiceResponse, error) { + c.requests <- request + return &collectortracev1.ExportTraceServiceResponse{}, nil +} + +// startTraceCapture serves an in-process OTLP/gRPC trace receiver on loopback. +func startTraceCapture(t *testing.T) (string, *traceCapture) { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + capture := &traceCapture{requests: make(chan *collectortracev1.ExportTraceServiceRequest, 4)} + server := grpc.NewServer() + collectortracev1.RegisterTraceServiceServer(server, capture) + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + return listener.Addr().String(), capture +} + +func shutdown(t *testing.T, provider interface{ Shutdown(context.Context) error }) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, provider.Shutdown(ctx)) +} diff --git a/module/services/accounts/code/telemetry_metrics.go b/module/services/accounts/code/telemetry_metrics.go index b088b2346..9ab41d0ea 100644 --- a/module/services/accounts/code/telemetry_metrics.go +++ b/module/services/accounts/code/telemetry_metrics.go @@ -2,59 +2,41 @@ package main import ( "context" - "net/http" runtimemetrics "runtime/metrics" "time" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" otelruntime "go.opentelemetry.io/contrib/instrumentation/runtime" "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" - otelprometheus "go.opentelemetry.io/otel/exporters/prometheus" otelmetric "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/sdk/metric" - "go.opentelemetry.io/otel/sdk/resource" ) +// otelMetrics owns the process's MeterProvider. Metrics leave by one path only: +// an OTLP push to the cell's collector, which is the record for traces and +// metrics alike. There is no scrape endpoint to mount, expose or exempt. type otelMetrics struct { provider *metric.MeterProvider - handler http.Handler } -func enableOTELMetrics(ctx context.Context, serviceName, endpoint string) (*otelMetrics, error) { - exporter, err := otlpmetricgrpc.New( - ctx, - otlpmetricgrpc.WithEndpoint(endpoint), - otlpmetricgrpc.WithInsecure(), - ) - if err != nil { - return nil, err +func enableOTELMetrics(ctx context.Context, destination telemetryDestination) (*otelMetrics, error) { + options := []otlpmetricgrpc.Option{otlpmetricgrpc.WithEndpoint(destination.Endpoint)} + if destination.Insecure { + // http://: plaintext on the wire, because the mesh supplies mTLS. An + // https:// endpoint takes the exporter's default, TLS. + options = append(options, otlpmetricgrpc.WithInsecure()) } - registry := prometheus.NewRegistry() - scrapeExporter, err := otelprometheus.New( - otelprometheus.WithRegisterer(registry), - otelprometheus.WithResourceAsConstantLabels( - attribute.NewAllowKeysFilter("service.name"), - ), - ) + exporter, err := otlpmetricgrpc.New(ctx, options...) if err != nil { return nil, err } - res, err := resource.New( - ctx, - resource.WithService(), - resource.WithFromEnv(), - resource.WithAttributes(attribute.String("service.name", serviceName)), - ) + res, err := currentTelemetryResource(ctx) if err != nil { return nil, err } provider := metric.NewMeterProvider( metric.WithResource(res), metric.WithReader(metric.NewPeriodicReader(exporter, metric.WithInterval(30*time.Second))), - metric.WithReader(scrapeExporter), ) if err := otelruntime.Start(otelruntime.WithMeterProvider(provider)); err != nil { _ = provider.Shutdown(ctx) @@ -65,10 +47,7 @@ func enableOTELMetrics(ctx context.Context, serviceName, endpoint string) (*otel return nil, err } otel.SetMeterProvider(provider) - return &otelMetrics{ - provider: provider, - handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}), - }, nil + return &otelMetrics{provider: provider}, nil } func startGCActivityMetrics(provider *metric.MeterProvider) error { @@ -102,10 +81,6 @@ func startGCActivityMetrics(provider *metric.MeterProvider) error { return err } -func (m *otelMetrics) Handler() http.Handler { - return m.handler -} - func (m *otelMetrics) Shutdown(ctx context.Context) error { return m.provider.Shutdown(ctx) } diff --git a/module/services/accounts/code/telemetry_metrics_test.go b/module/services/accounts/code/telemetry_metrics_test.go index 0429dcd90..fd7fd94af 100644 --- a/module/services/accounts/code/telemetry_metrics_test.go +++ b/module/services/accounts/code/telemetry_metrics_test.go @@ -60,6 +60,7 @@ func (versionConnectHandler) Version( func TestTelemetryMetricsExportRuntimeAndUnsampledRED(t *testing.T) { endpoint, capture := startMetricCapture(t) t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.instance.id=test-instance") + t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") previousMeterProvider := otel.GetMeterProvider() previousTracerProvider := otel.GetTracerProvider() @@ -70,7 +71,7 @@ func TestTelemetryMetricsExportRuntimeAndUnsampledRED(t *testing.T) { ) otel.SetTracerProvider(tracerProvider) - metrics, err := enableOTELMetrics(t.Context(), "test-service", endpoint) + metrics, err := enableOTELMetrics(t.Context(), telemetryDestination{Endpoint: endpoint, Insecure: true}) require.NoError(t, err) require.NotNil(t, metrics) t.Cleanup(func() { @@ -89,6 +90,7 @@ func TestTelemetryMetricsExportRuntimeAndUnsampledRED(t *testing.T) { require.NoError(t, metrics.provider.ForceFlush(t.Context())) request := receiveMetrics(t, capture.requests) require.Equal(t, "test-instance", resourceAttribute(request, "service.instance.id")) + require.Equal(t, "named-by-the-platform", resourceAttribute(request, "service.name")) metricNames := exportedMetricNames(request) require.Contains(t, metricNames, "go.goroutine.count") require.Contains(t, metricNames, "go.memory.allocated") @@ -107,24 +109,6 @@ func TestTelemetryMetricsExportRuntimeAndUnsampledRED(t *testing.T) { require.Equal(t, uint64(1), connectCounts["permission_denied"]) require.Empty(t, spanExporter.GetSpans()) - - recorder := httptest.NewRecorder() - metrics.Handler().ServeHTTP( - recorder, - httptest.NewRequest(http.MethodGet, "http://accounts.internal/metrics", nil), - ) - require.Equal(t, http.StatusOK, recorder.Code) - require.Contains(t, recorder.Header().Get("Content-Type"), "text/plain") - body := recorder.Body.String() - require.Contains(t, body, "go_goroutine_count") - require.Contains(t, body, "go_memory_allocated") - require.Contains(t, body, "go_memory_gc_goal") - require.Contains(t, body, "go_gc_cycle_count") - require.Contains(t, body, "go_gc_pause_cpu_time") - require.Contains(t, body, "rpc_server_call_duration_seconds_count") - require.Contains(t, body, `rpc_method="grpc.health.v1.Health/Check"`) - require.Contains(t, body, `rpc_response_status_code="OK"`) - require.Contains(t, body, `service_name="test-service"`) } func startMetricCapture(t *testing.T) (string, *metricCapture) { diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 3006cc8d0..b27571e53 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -38,7 +38,6 @@ import ( "time" "github.com/codefly-dev/core/wool" - wooltel "github.com/codefly-dev/core/wool/otel" codefly "github.com/codefly-dev/sdk-go" "github.com/google/uuid" "go.opentelemetry.io/otel" @@ -83,33 +82,22 @@ func doWork(ctx context.Context) (Clean, error) { return nil, err } - // When external observability is configured, OpenTelemetry always targets - // the in-graph collector. Codefly owns its host and port; the accounts - // process never reads or hardcodes a collector address. - var otelProvider *wooltel.Provider + // Traces and metrics go to the cell's collector, whose address the platform + // delivers in the `observability` configuration group — or the group says the + // cell has none. Either answer was already required before anything was + // acquired (requireStartupConfiguration); this reads it again to act on it. + telemetryDestination, err := configuredTelemetryDestination() + if err != nil { + return nil, err + } + // A local run with no cell collector gets wool's own stdout tracer instead. + otelProvider, err := enableTracing(ctx, telemetryDestination, codefly.IsLocal()) + if err != nil { + return nil, fmt.Errorf("configure OTEL tracing: %w", err) + } var otelMetricProvider *otelMetrics - if observabilityEnabled() { - collectorNetwork, err := codefly.For(ctx). - Service("telemetry"). - Endpoint("grpc"). - API("grpc"). - ResolveNetworkInstance() - if err != nil { - return nil, fmt.Errorf("resolve telemetry collector through Codefly: %w", err) - } - p, oerr := wooltel.Enable( - wooltel.WithServiceName("saas-starter-api"), - wooltel.WithEndpoint(collectorNetwork.Host), - wooltel.WithInsecure(), - ) - if oerr != nil { - return nil, fmt.Errorf("configure OTEL tracing: %w", oerr) - } - otelProvider = p - w.Info("OTEL enabled", - wool.Field("endpoint", collectorNetwork.Host), - wool.Field("service.name", "saas-starter-api")) - metricProvider, oerr := enableOTELMetrics(ctx, "saas-starter-api", collectorNetwork.Host) + if telemetryDestination.Available() { + metricProvider, oerr := enableOTELMetrics(ctx, telemetryDestination) if oerr != nil { shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) _ = otelProvider.Shutdown(shutdownCtx) @@ -117,8 +105,12 @@ func doWork(ctx context.Context) (Clean, error) { return nil, fmt.Errorf("configure OTEL metrics: %w", oerr) } otelMetricProvider = metricProvider - // codefly:gateway-route-exempt the OTEL scrape endpoint; the collector reaches it over the mesh and it must never be public - adapters.RegisterHTTPRoute("/metrics", otelMetricProvider.Handler()) + w.Info("OTEL enabled", + wool.Field("endpoint", telemetryDestination.Endpoint), + wool.Field("insecure", telemetryDestination.Insecure)) + } else { + w.Info("OTEL export disabled: the cell has no collector", + wool.Field("reason", telemetryDestination.AbsentReason)) } store, err := infra.NewPostgresStore(ctx) @@ -171,10 +163,10 @@ func doWork(ctx context.Context) (Clean, error) { } jobStore := infra.NewPostgresJobStore(jobWorkerPool) // Durable job-operations metrics are enabled only when OTEL metrics are, i.e. - // otelMetricProvider != nil (observabilityEnabled()). With observability off - // the global meter is a no-op, so building the monitor would poll the job - // store every interval to feed instruments nothing can read; keep it nil and - // let the Start/Shutdown nil-checks below skip it entirely. + // otelMetricProvider != nil (the cell has a collector). Without one the global + // meter is a no-op, so building the monitor would poll the job store every + // interval to feed instruments nothing can read; keep it nil and let the + // Start/Shutdown nil-checks below skip it entirely. jobOperationsMonitor, err := newDurableJobMetricsMonitor(otelMetricProvider != nil, jobStore) if err != nil { return nil, fmt.Errorf("configure durable job metrics: %w", err) @@ -1664,13 +1656,9 @@ func workspaceEnv(configuration, key string) string { return os.Getenv(key) } -func observabilityEnabled() bool { - return strings.TrimSpace(workspaceEnv("observability", "OTEL_EXPORTER_OTLP_ENDPOINT")) != "" -} - // newDurableJobMetricsMonitor builds the durable job-operations metrics monitor // only when OTEL metrics are enabled (metricsEnabled mirrors a non-nil -// otelMetricProvider, i.e. observabilityEnabled()). When metrics are disabled the +// otelMetricProvider, i.e. the cell has a collector). When metrics are disabled the // global meter is a no-op, so a monitor would poll the job store every interval // only to record into instruments nothing can read; returning a nil monitor lets // the caller's Start/Shutdown nil-checks skip that background work entirely. @@ -2231,7 +2219,21 @@ func loadSigningKey(ctx context.Context, allowEphemeral, isLocal bool) (ed25519c // deployment can still be fixed rather than discovered from a crash loop — and // so a test can reach all of them with nothing running. func requireStartupConfiguration(isLocal bool) error { - return requireKeyCustody(isLocal) + if err := requireKeyCustody(isLocal); err != nil { + return err + } + return requireTelemetryConfiguration() +} + +// requireTelemetryConfiguration refuses to start unless the `observability` +// group says where the cell's collector is, or that the cell has none. It only +// checks: doWork reads the same answer again to act on it. A group that did not +// arrive is refused here, before the database or Vault is touched, because +// reading it as "no collector" would leave a cell exporting nothing while it +// converges green. +func requireTelemetryConfiguration() error { + _, err := configuredTelemetryDestination() + return err } // requireKeyCustody refuses to start outside the local environment without the diff --git a/module/services/accounts/code/work_auth_test.go b/module/services/accounts/code/work_auth_test.go index 57f6e0d4d..dc6bc8160 100644 --- a/module/services/accounts/code/work_auth_test.go +++ b/module/services/accounts/code/work_auth_test.go @@ -77,25 +77,6 @@ func TestWorkspaceEnvPreservesExactCodeflyConfigurationName(t *testing.T) { require.Equal(t, "legacy-normalized", workspaceEnv("internal-auth", key)) } -func TestObservabilityEnabledRequiresExternalOTLPEndpoint(t *testing.T) { - const key = "OTEL_EXPORTER_OTLP_ENDPOINT" - configurationKey := "CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__" + key - secretKey := "CODEFLY__WORKSPACE_SECRET_CONFIGURATION__OBSERVABILITY__" + key - t.Setenv(key, "") - t.Setenv(configurationKey, "") - t.Setenv(secretKey, "") - require.False(t, observabilityEnabled()) - - t.Setenv(key, " ") - require.False(t, observabilityEnabled()) - t.Setenv(key, "https://otel.example") - require.True(t, observabilityEnabled()) - - t.Setenv(key, "") - t.Setenv(configurationKey, "https://workspace-otel.example") - require.True(t, observabilityEnabled()) -} - func TestConfiguredMFAStepUpMaxAge(t *testing.T) { t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__SECURITY__MFA_STEP_UP_MAX_AGE", "") t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__SECURITY__MFA_STEP_UP_MAX_AGE", "") diff --git a/module/services/auth-gateway/code/configuration.go b/module/services/auth-gateway/code/configuration.go index 45b601c36..b557942fe 100644 --- a/module/services/auth-gateway/code/configuration.go +++ b/module/services/auth-gateway/code/configuration.go @@ -2,15 +2,10 @@ package main import ( "os" - "strings" codefly "github.com/codefly-dev/sdk-go" ) -func observabilityEnabled() bool { - return strings.TrimSpace(workspaceEnv("observability", "OTEL_EXPORTER_OTLP_ENDPOINT")) != "" -} - // workspaceEnv resolves Codefly workspace configuration and secret values // before falling back to a raw environment variable. Services must use this // boundary for declared workspace-configuration-dependencies; Codefly keeps diff --git a/module/services/auth-gateway/code/configuration_test.go b/module/services/auth-gateway/code/configuration_test.go index 8c66bd328..513f79245 100644 --- a/module/services/auth-gateway/code/configuration_test.go +++ b/module/services/auth-gateway/code/configuration_test.go @@ -27,22 +27,3 @@ func TestWorkspaceEnvResolvesConfigurationSecretAndFallback(t *testing.T) { t.Setenv(configurationKey, "configuration") require.Equal(t, "configuration", workspaceEnv("internal-auth", key)) } - -func TestObservabilityEnabledRequiresExternalOTLPEndpoint(t *testing.T) { - const key = "OTEL_EXPORTER_OTLP_ENDPOINT" - configurationKey := "CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__" + key - secretKey := "CODEFLY__WORKSPACE_SECRET_CONFIGURATION__OBSERVABILITY__" + key - t.Setenv(key, "") - t.Setenv(configurationKey, "") - t.Setenv(secretKey, "") - require.False(t, observabilityEnabled()) - - t.Setenv(key, " ") - require.False(t, observabilityEnabled()) - t.Setenv(key, "https://otel.example") - require.True(t, observabilityEnabled()) - - t.Setenv(key, "") - t.Setenv(configurationKey, "https://workspace-otel.example") - require.True(t, observabilityEnabled()) -} diff --git a/module/services/auth-gateway/code/go.mod b/module/services/auth-gateway/code/go.mod index 35024443d..d9c26a210 100644 --- a/module/services/auth-gateway/code/go.mod +++ b/module/services/auth-gateway/code/go.mod @@ -11,14 +11,12 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 - github.com/prometheus/client_golang v1.24.1 github.com/redis/go-redis/v9 v9.22.0 github.com/stretchr/testify v1.12.1 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0 go.opentelemetry.io/otel v1.46.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 - go.opentelemetry.io/otel/exporters/prometheus v0.68.0 go.opentelemetry.io/otel/metric v1.46.0 go.opentelemetry.io/otel/sdk v1.46.0 go.opentelemetry.io/otel/sdk/metric v1.46.0 @@ -38,7 +36,6 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/VividCortex/ewma v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -78,15 +75,10 @@ require ( github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.27 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/client_model v0.6.3 // indirect - github.com/prometheus/common v0.71.0 // indirect - github.com/prometheus/otlptranslator v1.0.0 // indirect - github.com/prometheus/procfs v0.22.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.2.1 // indirect @@ -101,6 +93,7 @@ require ( go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 // indirect go.opentelemetry.io/otel/trace v1.46.0 // indirect go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect golang.org/x/net v0.59.0 // indirect diff --git a/module/services/auth-gateway/code/go.sum b/module/services/auth-gateway/code/go.sum index 1162c0299..6be460d10 100644 --- a/module/services/auth-gateway/code/go.sum +++ b/module/services/auth-gateway/code/go.sum @@ -20,8 +20,6 @@ github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1o github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -175,16 +173,12 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= -github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= @@ -201,8 +195,6 @@ github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -218,16 +210,6 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= -github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= -github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= -github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= -github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8= -github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk= -github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= -github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= -github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= -github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= @@ -278,8 +260,6 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzb go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY= -go.opentelemetry.io/otel/exporters/prometheus v0.68.0 h1:QOf2IftqQwITVRJpnn0M7M9ZCbgWfxz4P7i9C9yc2N4= -go.opentelemetry.io/otel/exporters/prometheus v0.68.0/go.mod h1:bgSvqu2TWGXiz7yr5UTMfObH8oqxJWHTnubQ3ef9BO4= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0/go.mod h1:K/qSA+3G7Eovxi4K09wzrAgkWRnosS0DAOZeEpve7sM= go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= diff --git a/module/services/auth-gateway/code/main.go b/module/services/auth-gateway/code/main.go index a1ebb1a3e..33a03c8bb 100644 --- a/module/services/auth-gateway/code/main.go +++ b/module/services/auth-gateway/code/main.go @@ -33,6 +33,14 @@ func main() { panic(fmt.Sprintf("codefly init failed: %v", err)) } + // The cell's collector is delivered in the `observability` configuration + // group, or the group says the cell has none. A group that did not arrive is + // neither, so it is refused before anything else starts. + telemetryDestination, err := configuredTelemetryDestination() + if err != nil { + panic(err) + } + grpcPort := codefly.For(ctx).WithDefaultNetwork().API(standards.GRPC).NetworkInstance().Port var httpPort uint16 httpNet, httpNetErr := codefly.For(ctx).API(standards.REST).ResolveNetworkInstance() @@ -40,32 +48,23 @@ func main() { panic(fmt.Sprintf("Codefly REST endpoint is unavailable: %v", httpNetErr)) } httpPort = httpNet.Port - var otelProvider *wooltel.Provider + // A local run with no cell collector gets wool's own stdout tracer instead. + otelProvider, err := enableTracing(ctx, telemetryDestination, codefly.IsLocal()) + if err != nil { + panic(fmt.Sprintf("configure OTEL tracing: %v", err)) + } var otelMetricProvider *otelMetrics - if observabilityEnabled() { - collectorNetwork, err := codefly.For(ctx). - Service("telemetry"). - Endpoint("grpc"). - API("grpc"). - ResolveNetworkInstance() - if err != nil { - panic(fmt.Sprintf("resolve telemetry collector through Codefly: %v", err)) - } - otelProvider, err = wooltel.Enable( - wooltel.WithServiceName("saas-starter-auth-gateway"), - wooltel.WithEndpoint(collectorNetwork.Host), - wooltel.WithInsecure(), - ) - if err != nil { - panic(fmt.Sprintf("configure OTEL tracing: %v", err)) - } - otelMetricProvider, err = enableOTELMetrics(ctx, "saas-starter-auth-gateway", collectorNetwork.Host) + if telemetryDestination.Available() { + otelMetricProvider, err = enableOTELMetrics(ctx, telemetryDestination) if err != nil { shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) _ = otelProvider.Shutdown(shutdownCtx) cancel() panic(fmt.Sprintf("configure OTEL metrics: %v", err)) } + log.Printf("OTEL enabled: endpoint=%s insecure=%t", telemetryDestination.Endpoint, telemetryDestination.Insecure) + } else { + log.Printf("OTEL export disabled: the cell has no collector: %s", telemetryDestination.AbsentReason) } defer func() { shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/module/services/auth-gateway/code/telemetry_destination.go b/module/services/auth-gateway/code/telemetry_destination.go new file mode 100644 index 000000000..e3296805d --- /dev/null +++ b/module/services/auth-gateway/code/telemetry_destination.go @@ -0,0 +1,267 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "strings" + + "github.com/codefly-dev/core/wool" + wooltel "github.com/codefly-dev/core/wool/otel" + codefly "github.com/codefly-dev/sdk-go" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/sdk/resource" +) + +// Where this process sends traces and metrics — or that it sends none. +// +// The platform tells a workload through the `observability` configuration +// group, and the group always says one of two things: +// +// TELEMETRY_STATE=available with OTEL_EXPORTER_OTLP_ENDPOINT, the cell's +// collector (OTLP/gRPC), e.g. +// http://otel-collector.otel-collector.svc.cluster.local:4317 +// TELEMETRY_STATE=absent with TELEMETRY_ABSENT_REASON, why the cell has no +// collector +// +// `http://` is plaintext on the wire because the service mesh supplies mTLS +// between workloads; `https://` is TLS, which the metrics exporter dials but +// wool's tracer cannot yet (see errWoolCannotDialTLS). This module owns no +// collector: the cell does, and a workload that cannot find it does not +// substitute one. +// +// A group that did not arrive is not a cell without a backend. Reading the first +// as the second would leave a deployment that converges green and exports +// nothing, so anything that is not exactly one of the two answers above refuses +// to start, naming what is missing. +// +// This file exists byte for byte in services/accounts/code and +// services/auth-gateway/code: the two services are independent Go modules with +// no shared package, and module/tools/telemetry_destination_mirror_test.go holds +// the copies identical. Change one, copy it over the other. + +const ( + telemetryStateKey = "TELEMETRY_STATE" + telemetryEndpointKey = "OTEL_EXPORTER_OTLP_ENDPOINT" + telemetryAbsentReasonKey = "TELEMETRY_ABSENT_REASON" + + telemetryStateAvailable = "available" + telemetryStateAbsent = "absent" + + // maxAbsentReasonLength bounds the operator-written reason before it reaches + // a log line: an unbounded value from configuration would otherwise let + // whoever writes the group forge log entries around it. + maxAbsentReasonLength = 300 +) + +// telemetryDestination is the resolved answer. Exactly one of Endpoint and +// AbsentReason is set. +type telemetryDestination struct { + // Endpoint is the collector's OTLP/gRPC address as host:port, the form the + // exporters take. Empty when the cell has no collector. + Endpoint string + // Insecure is true when the endpoint was http://, which the mesh protects. + // It is derived from the endpoint's scheme and never assumed. + Insecure bool + // AbsentReason says why the cell has no collector. Empty when Endpoint is set. + AbsentReason string +} + +// Available reports whether the cell has a collector to export to. +func (d telemetryDestination) Available() bool { + return d.Endpoint != "" +} + +// observabilityValue reads one key of the `observability` group through the +// SDK. It deliberately has no fallback to a bare process variable: an ambient +// OTEL_EXPORTER_OTLP_ENDPOINT in a shell or a pod is conventionally the +// OTLP/HTTP address, not the cell's gRPC collector, and reading it here would +// export to the wrong place without a word. +func observabilityValue(key string) string { + value, err := codefly.For(codefly.Context()).WorkspaceValue("observability", key) + if err != nil { + return "" + } + return value +} + +// configuredTelemetryDestination resolves the destination from the +// `observability` group. +func configuredTelemetryDestination() (telemetryDestination, error) { + return resolveTelemetryDestination(observabilityValue) +} + +// resolveTelemetryDestination is the whole decision, over any reader of the +// group's keys so a test can reach every outcome with nothing running. +func resolveTelemetryDestination(read func(key string) string) (telemetryDestination, error) { + state := strings.TrimSpace(read(telemetryStateKey)) + endpoint := strings.TrimSpace(read(telemetryEndpointKey)) + reason := boundedLine(read(telemetryAbsentReasonKey), maxAbsentReasonLength) + + if state == "" { + return telemetryDestination{}, errors.New( + "observability: " + telemetryStateKey + " is not set in the `observability` configuration group. " + + "The platform delivers `" + telemetryStateAvailable + "` (with " + telemetryEndpointKey + ") or `" + + telemetryStateAbsent + "` (with " + telemetryAbsentReasonKey + "); a group that did not arrive is " + + "not a cell without a collector, so this process does not start without it") + } + if state != telemetryStateAvailable && state != telemetryStateAbsent { + return telemetryDestination{}, fmt.Errorf( + "observability: %s is %q in the `observability` configuration group; it must be `%s` or `%s`", + telemetryStateKey, state, telemetryStateAvailable, telemetryStateAbsent) + } + if endpoint != "" && reason != "" { + return telemetryDestination{}, fmt.Errorf( + "observability: the `observability` configuration group sets both %s and %s; "+ + "a cell either has a collector or says why it has none", + telemetryEndpointKey, telemetryAbsentReasonKey) + } + + if state == telemetryStateAbsent { + if reason == "" { + return telemetryDestination{}, fmt.Errorf( + "observability: %s is `%s` but %s is not set in the `observability` configuration group; "+ + "an absent collector must say why", + telemetryStateKey, telemetryStateAbsent, telemetryAbsentReasonKey) + } + return telemetryDestination{AbsentReason: reason}, nil + } + + if endpoint == "" { + return telemetryDestination{}, fmt.Errorf( + "observability: %s is `%s` but %s is not set in the `observability` configuration group", + telemetryStateKey, telemetryStateAvailable, telemetryEndpointKey) + } + hostPort, insecure, err := parseCollectorEndpoint(endpoint) + if err != nil { + return telemetryDestination{}, fmt.Errorf("observability: %s: %w", telemetryEndpointKey, err) + } + return telemetryDestination{Endpoint: hostPort, Insecure: insecure}, nil +} + +// parseCollectorEndpoint turns the delivered URL into the host:port the OTLP +// gRPC exporters take, and reads the transport off its scheme. The URL is never +// echoed back: a malformed one may carry credentials. +func parseCollectorEndpoint(raw string) (hostPort string, insecure bool, err error) { + parsed, parseErr := url.Parse(raw) + if parseErr != nil { + return "", false, errors.New("is not a URL; expected http://host:port or https://host:port") + } + switch parsed.Scheme { + case "http": + insecure = true + case "https": + default: + return "", false, fmt.Errorf("must start with http:// or https://, got scheme %q", parsed.Scheme) + } + if parsed.Hostname() == "" { + return "", false, errors.New("has no host") + } + if parsed.User != nil { + return "", false, errors.New("must not carry credentials") + } + // OTLP/gRPC addresses a collector, not a path: a path or a query is the + // OTLP/HTTP form (/v1/traces), which this exporter does not speak. + if (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", false, errors.New("must be the collector's origin only, with no path, query or fragment") + } + port := parsed.Port() + if port == "" { + port = "80" + if !insecure { + port = "443" + } + } + return net.JoinHostPort(parsed.Hostname(), port), insecure, nil +} + +// boundedLine collapses a free-text value to one bounded line. +func boundedLine(value string, limit int) string { + line := strings.Join(strings.Fields(value), " ") + if runes := []rune(line); len(runes) > limit { + return string(runes[:limit]) + "…" + } + return line +} + +// telemetryResource is the one resource both signals carry. service.name is +// whatever the environment says — OTEL_SERVICE_NAME, or service.name in +// OTEL_RESOURCE_ATTRIBUTES — and otherwise the service identity the Codefly SDK +// holds. No name is typed here: a literal would override the environment, so the +// platform's identity could never reach the backend. +func telemetryResource(ctx context.Context, identity string) (*resource.Resource, error) { + options := []resource.Option{resource.WithService()} + if identity != "" { + options = append(options, resource.WithAttributes(attribute.String("service.name", identity))) + } + // Last, so what the environment declares wins over the identity above. + options = append(options, resource.WithFromEnv()) + return resource.New(ctx, options...) +} + +// currentTelemetryResource is telemetryResource for this process. +func currentTelemetryResource(ctx context.Context) (*resource.Resource, error) { + return telemetryResource(ctx, codeflyServiceName(codefly.Context())) +} + +// codeflyServiceName is the service identity the Codefly SDK holds: the +// `module/service` identity of the provider it built at Init, which wool stamps on +// every log line from this process. It is empty when the SDK has not been +// initialised. The SDK exposes no accessor of its own, and the environment +// carrier it reads this from is not product code's to read, so this asks the +// provider. +func codeflyServiceName(ctx context.Context) string { + provider, ok := ctx.Value(wool.ProviderKey).(*wool.Provider) + if !ok || provider == nil { + return "" + } + source := provider.Get(ctx).Source() + if source == nil { + return "" + } + // An identity with no module reads `/service`; with neither, `/`. + return strings.Trim(strings.TrimSpace(source.Unique), "/") +} + +// telemetryServiceName is the service.name the resource resolved to. +func telemetryServiceName(res *resource.Resource) string { + if value, ok := res.Set().Value(attribute.Key("service.name")); ok { + return value.AsString() + } + return "" +} + +// errWoolCannotDialTLS names the one transport the tracer cannot serve. +var errWoolCannotDialTLS = errors.New( + "the collector endpoint is https://, but wool's OTLP tracer cannot dial TLS: wool/otel.Enable " + + "always sets an insecure transport and offers no option to turn it off. Deliver an http:// " + + "endpoint (in-mesh, where the mesh supplies mTLS) until wool can dial TLS") + +// enableTracing registers wool's tracer for the destination: OTLP/gRPC to the +// cell's collector when there is one; wool's own stdout tracer when there is +// none and this is a local run; nothing otherwise. A nil provider means nothing +// was registered. +func enableTracing(ctx context.Context, destination telemetryDestination, local bool) (*wooltel.Provider, error) { + res, err := currentTelemetryResource(ctx) + if err != nil { + return nil, fmt.Errorf("resolve telemetry resource: %w", err) + } + options := []wooltel.Option{wooltel.WithServiceName(telemetryServiceName(res))} + switch { + case destination.Available(): + // wool dials plaintext unless told otherwise and cannot be told + // otherwise, so an https:// endpoint is refused rather than dialed in + // the clear and reported as TLS. + if !destination.Insecure { + return nil, errWoolCannotDialTLS + } + options = append(options, wooltel.WithEndpoint(destination.Endpoint), wooltel.WithInsecure()) + case local: + options = append(options, wooltel.WithStdout()) + default: + return nil, nil + } + return wooltel.Enable(options...) +} diff --git a/module/services/auth-gateway/code/telemetry_destination_test.go b/module/services/auth-gateway/code/telemetry_destination_test.go new file mode 100644 index 000000000..4d81a7698 --- /dev/null +++ b/module/services/auth-gateway/code/telemetry_destination_test.go @@ -0,0 +1,376 @@ +package main + +import ( + "context" + "net" + "strings" + "testing" + "time" + + "github.com/codefly-dev/core/wool" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + collectortracev1 "go.opentelemetry.io/proto/otlp/collector/trace/v1" + "google.golang.org/grpc" +) + +// group is a reader over a fixed set of `observability` keys. +func group(values map[string]string) func(string) string { + return func(key string) string { return values[key] } +} + +const collector = "http://otel-collector.otel-collector.svc.cluster.local:4317" + +// The first outcome: the platform delivered a collector. +func TestTelemetryDestinationAvailableExportsToTheEndpoint(t *testing.T) { + destination, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "available", + "OTEL_EXPORTER_OTLP_ENDPOINT": collector, + })) + require.NoError(t, err) + require.True(t, destination.Available()) + require.Equal(t, "otel-collector.otel-collector.svc.cluster.local:4317", destination.Endpoint) + require.True(t, destination.Insecure, "http:// is plaintext on the wire; the mesh supplies mTLS") + require.Empty(t, destination.AbsentReason) +} + +// The transport comes from the endpoint's scheme, never from a constant. +func TestTelemetryDestinationReadsTransportFromTheScheme(t *testing.T) { + for endpoint, want := range map[string]struct { + hostPort string + insecure bool + }{ + "http://collector.example:4317": {"collector.example:4317", true}, + "https://collector.example:4317": {"collector.example:4317", false}, + "https://collector.example": {"collector.example:443", false}, + "http://collector.example": {"collector.example:80", true}, + "http://collector.example:4317/": {"collector.example:4317", true}, + " http://collector.example:4317 ": {"collector.example:4317", true}, + "http://[2001:db8::1]:4317": {"[2001:db8::1]:4317", true}, + "https://10.0.0.7:4317": {"10.0.0.7:4317", false}, + "http://otel-collector.ns.svc:4317/": {"otel-collector.ns.svc:4317", true}, + } { + destination, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "available", + "OTEL_EXPORTER_OTLP_ENDPOINT": endpoint, + })) + require.NoError(t, err, endpoint) + require.Equal(t, want.hostPort, destination.Endpoint, endpoint) + require.Equal(t, want.insecure, destination.Insecure, endpoint) + } +} + +// The second outcome: the cell has no collector, and says why. The process boots +// and exports nothing. +func TestTelemetryDestinationAbsentCarriesTheReason(t *testing.T) { + destination, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "absent", + "TELEMETRY_ABSENT_REASON": "This cell's bucket has no ops cell, so no collector is rendered.", + })) + require.NoError(t, err) + require.False(t, destination.Available()) + require.Empty(t, destination.Endpoint) + require.Equal(t, "This cell's bucket has no ops cell, so no collector is rendered.", destination.AbsentReason) +} + +// The reason is operator-written free text that reaches a log line. +func TestTelemetryDestinationAbsentReasonIsOneBoundedLine(t *testing.T) { + destination, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "absent", + "TELEMETRY_ABSENT_REASON": "no collector\n2026-01-01 FORGED entry\r\n" + strings.Repeat("x", 1000), + })) + require.NoError(t, err) + require.NotContains(t, destination.AbsentReason, "\n") + require.NotContains(t, destination.AbsentReason, "\r") + require.LessOrEqual(t, len([]rune(destination.AbsentReason)), maxAbsentReasonLength+1) +} + +// The third outcome: anything else refuses to start, naming what is wrong. +// Configuration that did not arrive is never read as "no collector". +func TestTelemetryDestinationRefusesEverythingElse(t *testing.T) { + for name, tc := range map[string]struct { + values map[string]string + want string + }{ + "state missing": { + values: map[string]string{}, + want: "TELEMETRY_STATE is not set", + }, + "state missing though an ambient endpoint is present": { + values: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": collector}, + want: "TELEMETRY_STATE is not set", + }, + "state missing though a reason is present": { + values: map[string]string{"TELEMETRY_ABSENT_REASON": "no collector"}, + want: "TELEMETRY_STATE is not set", + }, + "state blank": { + values: map[string]string{"TELEMETRY_STATE": " "}, + want: "TELEMETRY_STATE is not set", + }, + "state unknown": { + values: map[string]string{"TELEMETRY_STATE": "disabled", "TELEMETRY_ABSENT_REASON": "no collector"}, + want: `TELEMETRY_STATE is "disabled"`, + }, + "state is not matched case-insensitively": { + values: map[string]string{"TELEMETRY_STATE": "Available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, + want: `TELEMETRY_STATE is "Available"`, + }, + "available without an endpoint": { + values: map[string]string{"TELEMETRY_STATE": "available"}, + want: "OTEL_EXPORTER_OTLP_ENDPOINT is not set", + }, + "available with only a reason": { + values: map[string]string{"TELEMETRY_STATE": "available", "TELEMETRY_ABSENT_REASON": "no collector"}, + want: "OTEL_EXPORTER_OTLP_ENDPOINT is not set", + }, + "absent without a reason": { + values: map[string]string{"TELEMETRY_STATE": "absent"}, + want: "TELEMETRY_ABSENT_REASON is not set", + }, + "absent with a blank reason": { + values: map[string]string{"TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": " \n "}, + want: "TELEMETRY_ABSENT_REASON is not set", + }, + "absent with only an endpoint": { + values: map[string]string{"TELEMETRY_STATE": "absent", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, + want: "TELEMETRY_ABSENT_REASON is not set", + }, + "available with both an endpoint and a reason": { + values: map[string]string{ + "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, + "TELEMETRY_ABSENT_REASON": "no collector", + }, + want: "sets both OTEL_EXPORTER_OTLP_ENDPOINT and TELEMETRY_ABSENT_REASON", + }, + "absent with both an endpoint and a reason": { + values: map[string]string{ + "TELEMETRY_STATE": "absent", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, + "TELEMETRY_ABSENT_REASON": "no collector", + }, + want: "sets both OTEL_EXPORTER_OTLP_ENDPOINT and TELEMETRY_ABSENT_REASON", + }, + "endpoint without a scheme": { + values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "otel-collector.ns.svc:4317"}, + want: "must start with http:// or https://", + }, + "endpoint with another scheme": { + values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "grpc://otel-collector.ns.svc:4317"}, + want: `got scheme "grpc"`, + }, + "endpoint without a host": { + values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://:4317"}, + want: "has no host", + }, + "endpoint naming an OTLP/HTTP path": { + values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector.example:4318/v1/traces"}, + want: "origin only", + }, + "endpoint with a query": { + values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector.example:4317?x=1"}, + want: "origin only", + }, + } { + t.Run(name, func(t *testing.T) { + destination, err := resolveTelemetryDestination(group(tc.values)) + require.Error(t, err) + require.ErrorContains(t, err, tc.want) + require.False(t, destination.Available(), "a refusal must never read as a usable destination") + require.Empty(t, destination.AbsentReason, "a refusal must never read as an absent collector") + }) + } +} + +// A refusal quotes no part of an endpoint, which may carry credentials. +func TestTelemetryDestinationRefusalDoesNotEchoCredentials(t *testing.T) { + _, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "available", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://user:hunter2@collector.example:4317", + })) + require.ErrorContains(t, err, "must not carry credentials") + require.NotContains(t, err.Error(), "hunter2") +} + +// The group is read through the SDK's workspace-configuration lookup, by the +// names the platform delivers them under. A bare process variable of the same +// name is not the cell's answer. +func TestConfiguredTelemetryDestinationReadsTheObservabilityGroup(t *testing.T) { + const prefix = "CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__" + for _, key := range []string{"TELEMETRY_STATE", "OTEL_EXPORTER_OTLP_ENDPOINT", "TELEMETRY_ABSENT_REASON"} { + t.Setenv(key, "") + t.Setenv(prefix+key, "") + } + + _, err := configuredTelemetryDestination() + require.ErrorContains(t, err, "TELEMETRY_STATE is not set") + + // Ambient variables a shell or a pod may carry are not the group. + t.Setenv("TELEMETRY_STATE", "available") + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://ambient.example:4318") + _, err = configuredTelemetryDestination() + require.ErrorContains(t, err, "TELEMETRY_STATE is not set") + t.Setenv("TELEMETRY_STATE", "") + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "") + + t.Setenv(prefix+"TELEMETRY_STATE", "available") + t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", collector) + destination, err := configuredTelemetryDestination() + require.NoError(t, err) + require.Equal(t, "otel-collector.otel-collector.svc.cluster.local:4317", destination.Endpoint) + require.True(t, destination.Insecure) + + t.Setenv(prefix+"TELEMETRY_STATE", "absent") + t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", "") + t.Setenv(prefix+"TELEMETRY_ABSENT_REASON", "a local run has no cell collector") + destination, err = configuredTelemetryDestination() + require.NoError(t, err) + require.False(t, destination.Available()) + require.Equal(t, "a local run has no cell collector", destination.AbsentReason) +} + +// service.name is never a literal typed here: the environment wins, then the +// service's own Codefly identity. +func TestTelemetryResourceServiceName(t *testing.T) { + resolve := func(t *testing.T, identity string) string { + t.Helper() + res, err := telemetryResource(t.Context(), identity) + require.NoError(t, err) + return telemetryServiceName(res) + } + + t.Run("falls back to the Codefly service identity", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + require.Equal(t, "saas-starter/accounts", resolve(t, "saas-starter/accounts")) + }) + t.Run("OTEL_SERVICE_NAME wins over the identity", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + require.Equal(t, "named-by-the-platform", resolve(t, "saas-starter/accounts")) + }) + t.Run("OTEL_RESOURCE_ATTRIBUTES can set it", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.name=attributed-by-the-platform,deployment.environment=test") + require.Equal(t, "attributed-by-the-platform", resolve(t, "saas-starter/accounts")) + }) + t.Run("without either, the SDK default rather than a literal", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + require.True(t, strings.HasPrefix(resolve(t, ""), "unknown_service"), resolve(t, "")) + }) +} + +// The identity is the one the SDK built its provider with, the same one wool +// stamps on this process's log lines — read from the provider, not from the +// runtime's environment carrier. +func TestCodeflyServiceNameIsTheSDKProviderIdentity(t *testing.T) { + carrying := func(unique string) context.Context { + return wool.New(t.Context(), &wool.Resource{Kind: "service", Unique: unique}).Inject(t.Context()) + } + require.Equal(t, "saas-starter/accounts", codeflyServiceName(carrying("saas-starter/accounts"))) + require.Equal(t, "accounts", codeflyServiceName(carrying("/accounts")), "an identity with no module") + require.Empty(t, codeflyServiceName(carrying("/")), "an identity with neither") + require.Empty(t, codeflyServiceName(t.Context()), "no provider: the SDK was not initialised") +} + +// wool's tracer speaks plaintext only. An https:// endpoint is refused rather +// than dialed in the clear and reported as TLS. +func TestEnableTracingRefusesATransportWoolCannotDial(t *testing.T) { + provider, err := enableTracing(t.Context(), + telemetryDestination{Endpoint: "collector.example:4317", Insecure: false}, false) + require.ErrorIs(t, err, errWoolCannotDialTLS) + require.Nil(t, provider) +} + +func TestEnableTracingRegistersOnlyWhatTheDestinationCalls(t *testing.T) { + restore := func(t *testing.T) { + t.Helper() + previous := otel.GetTracerProvider() + t.Cleanup(func() { + wool.RegisterTelemetry(nil) + otel.SetTracerProvider(previous) + }) + } + absent := telemetryDestination{AbsentReason: "a local run has no cell collector"} + + t.Run("absent outside a local run registers no tracer", func(t *testing.T) { + restore(t) + provider, err := enableTracing(t.Context(), absent, false) + require.NoError(t, err) + require.Nil(t, provider) + require.False(t, wool.TelemetryEnabled()) + }) + t.Run("absent in a local run uses wool's stdout tracer", func(t *testing.T) { + restore(t) + provider, err := enableTracing(t.Context(), absent, true) + require.NoError(t, err) + require.NotNil(t, provider) + require.True(t, wool.TelemetryEnabled()) + shutdown(t, provider) + }) + t.Run("available over http exports spans to the collector, named by the environment", func(t *testing.T) { + restore(t) + t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + endpoint, capture := startTraceCapture(t) + provider, err := enableTracing(t.Context(), telemetryDestination{Endpoint: endpoint, Insecure: true}, false) + require.NoError(t, err) + require.NotNil(t, provider) + require.True(t, wool.TelemetryEnabled()) + + _, span := otel.Tracer("test").Start(t.Context(), "operation") + span.End() + shutdown(t, provider) // flushes the batch + + select { + case request := <-capture.requests: + var name string + for _, resourceSpans := range request.GetResourceSpans() { + for _, attribute := range resourceSpans.GetResource().GetAttributes() { + if attribute.GetKey() == "service.name" { + name = attribute.GetValue().GetStringValue() + } + } + } + require.Equal(t, "named-by-the-platform", name) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for OTLP spans") + } + }) +} + +type traceCapture struct { + collectortracev1.UnimplementedTraceServiceServer + requests chan *collectortracev1.ExportTraceServiceRequest +} + +func (c *traceCapture) Export( + _ context.Context, + request *collectortracev1.ExportTraceServiceRequest, +) (*collectortracev1.ExportTraceServiceResponse, error) { + c.requests <- request + return &collectortracev1.ExportTraceServiceResponse{}, nil +} + +// startTraceCapture serves an in-process OTLP/gRPC trace receiver on loopback. +func startTraceCapture(t *testing.T) (string, *traceCapture) { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + capture := &traceCapture{requests: make(chan *collectortracev1.ExportTraceServiceRequest, 4)} + server := grpc.NewServer() + collectortracev1.RegisterTraceServiceServer(server, capture) + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + return listener.Addr().String(), capture +} + +func shutdown(t *testing.T, provider interface{ Shutdown(context.Context) error }) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, provider.Shutdown(ctx)) +} diff --git a/module/services/auth-gateway/code/telemetry_metrics.go b/module/services/auth-gateway/code/telemetry_metrics.go index ad2e7f8e0..bcebfed60 100644 --- a/module/services/auth-gateway/code/telemetry_metrics.go +++ b/module/services/auth-gateway/code/telemetry_metrics.go @@ -6,56 +6,39 @@ import ( runtimemetrics "runtime/metrics" "time" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" otelruntime "go.opentelemetry.io/contrib/instrumentation/runtime" "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" - otelprometheus "go.opentelemetry.io/otel/exporters/prometheus" otelmetric "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/sdk/metric" - "go.opentelemetry.io/otel/sdk/resource" ) +// otelMetrics owns the process's MeterProvider. Metrics leave by one path only: +// an OTLP push to the cell's collector, which is the record for traces and +// metrics alike. There is no scrape endpoint to mount, expose or exempt. type otelMetrics struct { provider *metric.MeterProvider - handler http.Handler } -func enableOTELMetrics(ctx context.Context, serviceName, endpoint string) (*otelMetrics, error) { - exporter, err := otlpmetricgrpc.New( - ctx, - otlpmetricgrpc.WithEndpoint(endpoint), - otlpmetricgrpc.WithInsecure(), - ) - if err != nil { - return nil, err +func enableOTELMetrics(ctx context.Context, destination telemetryDestination) (*otelMetrics, error) { + options := []otlpmetricgrpc.Option{otlpmetricgrpc.WithEndpoint(destination.Endpoint)} + if destination.Insecure { + // http://: plaintext on the wire, because the mesh supplies mTLS. An + // https:// endpoint takes the exporter's default, TLS. + options = append(options, otlpmetricgrpc.WithInsecure()) } - registry := prometheus.NewRegistry() - scrapeExporter, err := otelprometheus.New( - otelprometheus.WithRegisterer(registry), - otelprometheus.WithResourceAsConstantLabels( - attribute.NewAllowKeysFilter("service.name"), - ), - ) + exporter, err := otlpmetricgrpc.New(ctx, options...) if err != nil { return nil, err } - res, err := resource.New( - ctx, - resource.WithService(), - resource.WithFromEnv(), - resource.WithAttributes(attribute.String("service.name", serviceName)), - ) + res, err := currentTelemetryResource(ctx) if err != nil { return nil, err } provider := metric.NewMeterProvider( metric.WithResource(res), metric.WithReader(metric.NewPeriodicReader(exporter, metric.WithInterval(30*time.Second))), - metric.WithReader(scrapeExporter), ) if err := otelruntime.Start(otelruntime.WithMeterProvider(provider)); err != nil { _ = provider.Shutdown(ctx) @@ -66,10 +49,7 @@ func enableOTELMetrics(ctx context.Context, serviceName, endpoint string) (*otel return nil, err } otel.SetMeterProvider(provider) - return &otelMetrics{ - provider: provider, - handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}), - }, nil + return &otelMetrics{provider: provider}, nil } func startGCActivityMetrics(provider *metric.MeterProvider) error { @@ -103,18 +83,14 @@ func startGCActivityMetrics(provider *metric.MeterProvider) error { return err } +// newGatewayHTTPHandler instruments the gateway's HTTP listener when metrics are +// on. It adds no route of its own: the listener serves the gateway's routes and +// nothing else, with or without telemetry. func newGatewayHTTPHandler(gateway http.Handler, metrics *otelMetrics) http.Handler { if metrics == nil { return gateway } - mux := http.NewServeMux() - mux.Handle("GET /metrics", metrics.Handler()) - mux.Handle("/", otelhttp.NewHandler(gateway, "auth-gateway.gateway")) - return mux -} - -func (m *otelMetrics) Handler() http.Handler { - return m.handler + return otelhttp.NewHandler(gateway, "auth-gateway.gateway") } func (m *otelMetrics) Shutdown(ctx context.Context) error { diff --git a/module/services/auth-gateway/code/telemetry_metrics_test.go b/module/services/auth-gateway/code/telemetry_metrics_test.go index 6e461dae4..701c6c851 100644 --- a/module/services/auth-gateway/code/telemetry_metrics_test.go +++ b/module/services/auth-gateway/code/telemetry_metrics_test.go @@ -39,8 +39,9 @@ func (c *authMetricCapture) Export( func TestAuthGatewayTelemetryExportsGatewayAndGRPCRED(t *testing.T) { endpoint, capture := startAuthMetricCapture(t) t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.instance.id=auth-test-instance") + t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") previousMeterProvider := otel.GetMeterProvider() - metrics, err := enableOTELMetrics(t.Context(), "test-auth-gateway", endpoint) + metrics, err := enableOTELMetrics(t.Context(), telemetryDestination{Endpoint: endpoint, Insecure: true}) require.NoError(t, err) require.NotNil(t, metrics) t.Cleanup(func() { @@ -57,19 +58,10 @@ func TestAuthGatewayTelemetryExportsGatewayAndGRPCRED(t *testing.T) { require.Equal(t, http.StatusNotFound, getStatus(t, httpServer.URL+"/missing")) runtime.GC() - response, err := http.Get(httpServer.URL + "/metrics") - require.NoError(t, err) - t.Cleanup(func() { _ = response.Body.Close() }) - require.Equal(t, http.StatusOK, response.StatusCode) - prometheusBody, err := io.ReadAll(response.Body) - require.NoError(t, err) - require.Contains(t, string(prometheusBody), "http_server_request_duration_seconds_count") - require.Contains(t, string(prometheusBody), `http_route="/health"`) - require.Contains(t, string(prometheusBody), `service_name="test-auth-gateway"`) - require.NoError(t, metrics.provider.ForceFlush(t.Context())) request := receiveAuthMetrics(t, capture.requests) require.Equal(t, "auth-test-instance", authResourceAttribute(request, "service.instance.id")) + require.Equal(t, "named-by-the-platform", authResourceAttribute(request, "service.name")) metricNames := authExportedMetricNames(request) require.Contains(t, metricNames, "go.goroutine.count") require.Contains(t, metricNames, "go.gc.cycle.count") @@ -88,19 +80,26 @@ func TestAuthGatewayTelemetryExportsGatewayAndGRPCRED(t *testing.T) { require.Equal(t, uint64(2), httpRoutes["/health"]) } -func TestGatewayHandlerHasNoMetricsRouteWhenTelemetryIsDisabled(t *testing.T) { +// Metrics leave by OTLP push alone: the gateway's listener serves its own routes +// and no scrape endpoint, whether or not telemetry is on. +func TestGatewayHandlerHasNoMetricsRoute(t *testing.T) { matcher := NewRouteMatcher([]*RouteEntry{{ Service: "self", Method: http.MethodGet, Path: "/health", }}, nil) gateway := NewGateway(nil, matcher, nil, nil, newFakeSolutionRegistry(), newFakeClientRegistry()) - request := httptest.NewRequest(http.MethodGet, "/metrics", nil) - recorder := httptest.NewRecorder() - newGatewayHTTPHandler(gateway, nil).ServeHTTP(recorder, request) - - require.Equal(t, http.StatusNotFound, recorder.Code) + for name, metrics := range map[string]*otelMetrics{ + "telemetry disabled": nil, + "telemetry enabled": {}, + } { + t.Run(name, func(t *testing.T) { + recorder := httptest.NewRecorder() + newGatewayHTTPHandler(gateway, metrics).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + require.Equal(t, http.StatusNotFound, recorder.Code) + }) + } } func authInt64SumValue(request *collectormetricsv1.ExportMetricsServiceRequest, name string) int64 { diff --git a/module/tools/telemetry_destination_mirror_test.go b/module/tools/telemetry_destination_mirror_test.go new file mode 100644 index 000000000..e7310335e --- /dev/null +++ b/module/tools/telemetry_destination_mirror_test.go @@ -0,0 +1,40 @@ +package tools + +import ( + "os" + "path/filepath" + "testing" +) + +// telemetryDestinationCopies are source files that exist in both Go services, +// keyed by the copy and valued by the file it must match. +// +// accounts and auth-gateway both resolve where the cell's collector is from the +// `observability` configuration group: the same three keys, the same three +// outcomes, the same refusal to start on anything else. They are separate Go +// modules with no go.work, so neither can import the other, and a shared module +// would be a third thing to version for one small decision. The copy is the +// price; this test keeps the price from being paid twice, because two producers +// that read the group differently would let one service export while the other +// refuses to start on the same configuration. +var telemetryDestinationCopies = map[string]string{ + "services/auth-gateway/code/telemetry_destination.go": "services/accounts/code/telemetry_destination.go", +} + +func TestTelemetryDestinationCopiesMatchTheirSource(t *testing.T) { + moduleDir := findModuleDir(t) + for copyPath, sourcePath := range telemetryDestinationCopies { + copyBody, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(copyPath))) + if err != nil { + t.Fatalf("read telemetry destination copy %s: %v", copyPath, err) + } + sourceBody, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(sourcePath))) + if err != nil { + t.Fatalf("read telemetry destination source %s: %v", sourcePath, err) + } + if string(copyBody) != string(sourceBody) { + t.Errorf("%s has diverged from %s; change the source and copy it over the other so both services resolve the collector the same way", + copyPath, sourcePath) + } + } +} From 7e0ee42a036b78d032a03812dc6c611704a70531 Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:44:54 -0400 Subject: [PATCH 02/20] feat(module)!: remove the telemetry service; the cell's collector replaces it (Refs #1022) `module/services/telemetry` was a hand-written in-graph OTLP gateway: a go-grpc service that registered the three upstream collector services and either logged each signal or re-marshalled it and POSTed it on. Every other upstream in this module is a published Codefly agent with a declarative spec; this one existed only because no collector agent did, and it was a worse collector than the one the cell already runs. An inline POST in the gRPC handler has no batching, no queue, no retry and no sampling, and a failed forward reaches the client as an unretryable `Unknown`, so every forward failure dropped that batch. LogsService was dead surface. accounts and auth-gateway now resolve the cell's collector from the `observability` group (previous commit), so the service has no caller. Removed: - the service: manifest, code with its own go.mod, proto and its SchemaMarker. - its dependency blocks in accounts and auth-gateway, its entry in module.codefly.yaml and in module.package.codefly.yaml. - the generated Codefly client stubs under `code/external/.../telemetry` in both services. `codefly sync service` does not prune an orphaned stub; with the dependency gone and the stubs deleted, a sync is a no-op in both services, which is what a from-scratch run produces. Regenerated, never edited, with `go generate ./pkg/cataloggen`: the topology loses the service, both dependency edges and the telemetry public-egress row, and the network-policy golden loses the five rules that named it. auth-gateway still has no public egress, which is now asserted: it reached a backend only by talking in-mesh to the gateway, and exporting to the cell's in-mesh collector needs no 443 allowance on the one service whose job is to be the perimeter. The topology and golden tests move with it, including the strict-spec cases that borrowed this service's manifest as a fixture. The module-package version is bumped 0.1.0 -> 0.2.0. Removing a service and making `TELEMETRY_STATE` mandatory is a breaking change for anyone composing the package and, at 0.x, that is a minor bump; the manifest records it in `breaking-changes`. `contracts/api/catalog.codefly.json` carries the package version, so it is regenerated with `codefly generate contracts saas-starter` (a one-line change) and the check passes. The reference composition's accepted range and the pin examples move to the 0.2 line, and a composition test fixture now takes the package version from the manifest instead of a literal. Counted prose corrected: seven services, twelve endpoints, six dependency edges, six interface endpoints, three public-egress grants and eighteen NetworkPolicy documents (the topology doc's endpoint, interface and policy counts were already stale against its own test); five Go modules by the enumeration given; the service graph and dependency tables; the collector-resolution paragraphs in the module, topology, analytics, SigNoz, features, local-dogfood and dogfooding docs and the run-locally skill. The metrics decision is recorded: OTLP push only, 2026-10-06. Left alone on purpose: the job-worker and analytics-outbox telemetry files and the synthetic `telemetry` fixture in the root module's mesh-policy test, which share only a word with this service. The point-in-time security review records still describe the former /metrics route as filed. Not done here: this does not bump the pinned codefly core, so the log/trace correlation fix is not yet consumed. Tested: the Go suites in accounts (pure), auth-gateway, module/tools, the root module and qualification/module-authority, the cataloggen parity tests, and the repository's node gates. Two module/tools tests fail identically on an untouched checkout in this environment (a missing `buf` binary, and a cache rename refused on macOS) and were not run to success. Co-Authored-By: Claude Opus 5.5 --- .../skills/run-the-starter-locally/SKILL.md | 11 +- AGENTS.md | 6 +- CLAIM_INVENTORY.md | 2 +- DOGFOODING.md | 6 +- LOCAL_DOGFOODING.md | 26 +- MODULE.md | 9 +- README.md | 2 +- RELEASE_GATES.md | 6 +- module/AGENTS.md | 14 +- module/DEPLOYMENT_TOPOLOGY.md | 27 +- module/FEATURES.md | 35 ++- module/PRODUCT_ANALYTICS.md | 7 +- module/SIGNOZ_PROVISIONING.md | 11 +- module/contracts/api/catalog.codefly.json | 2 +- .../contracts/reference/module.codefly.yaml | 2 +- .../generated/service-topology.json | 29 --- module/docs/composition-v2-migration.md | 4 +- module/module.codefly.yaml | 1 - module/module.package.codefly.yaml | 5 +- module/services/README.md | 2 +- module/services/accounts/TESTING.md | 4 +- .../saas-starter_telemetry_grpc.pb.go | 60 ----- .../code/pkg/auth/pg/session_store_test.go | 2 +- .../code/pkg/billing/pg/store_test.go | 2 +- .../code/pkg/business/service_test.go | 2 +- .../cataloggen/deployment_topology_test.go | 57 +++-- .../code/pkg/cataloggen/test_targets_test.go | 1 - .../testdata/network-policy.golden.yaml | 93 ------- .../code/pkg/infra/postgres_webhooks_test.go | 2 +- .../code/pkg/redisintegration/main_test.go | 2 +- module/services/accounts/service.codefly.yaml | 3 - .../saas-starter_telemetry_grpc.pb.go | 60 ----- .../auth-gateway/service.codefly.yaml | 3 - .../frontend/code/src/test/pipeline-setup.ts | 6 +- .../telemetry/code/collector/collector.go | 218 ---------------- .../code/collector/collector_test.go | 134 ---------- module/services/telemetry/code/go.mod | 58 ----- module/services/telemetry/code/go.sum | 235 ------------------ module/services/telemetry/code/main.go | 128 ---------- module/services/telemetry/code/main_test.go | 98 -------- .../gen/saas/telemetry/v1/schema_marker.pb.go | 117 --------- module/services/telemetry/proto/README.md | 9 - module/services/telemetry/proto/buf.gen.yaml | 11 - module/services/telemetry/proto/buf.yaml | 6 - .../saas/telemetry/v1/schema_marker.proto | 7 - .../services/telemetry/service.codefly.yaml | 26 -- module/tools/composition/composition_test.go | 9 +- .../tools/modulepackage/modulepackage_test.go | 6 +- 48 files changed, 154 insertions(+), 1412 deletions(-) delete mode 100644 module/services/accounts/code/external/saas-starter/telemetry/saas-starter_telemetry_grpc.pb.go delete mode 100644 module/services/auth-gateway/code/external/saas-starter/telemetry/saas-starter_telemetry_grpc.pb.go delete mode 100644 module/services/telemetry/code/collector/collector.go delete mode 100644 module/services/telemetry/code/collector/collector_test.go delete mode 100644 module/services/telemetry/code/go.mod delete mode 100644 module/services/telemetry/code/go.sum delete mode 100644 module/services/telemetry/code/main.go delete mode 100644 module/services/telemetry/code/main_test.go delete mode 100644 module/services/telemetry/code/pkg/gen/saas/telemetry/v1/schema_marker.pb.go delete mode 100644 module/services/telemetry/proto/README.md delete mode 100644 module/services/telemetry/proto/buf.gen.yaml delete mode 100644 module/services/telemetry/proto/buf.yaml delete mode 100644 module/services/telemetry/proto/saas/telemetry/v1/schema_marker.proto delete mode 100644 module/services/telemetry/service.codefly.yaml diff --git a/.claude/skills/run-the-starter-locally/SKILL.md b/.claude/skills/run-the-starter-locally/SKILL.md index 2c6a92afc..1f96ddc95 100644 --- a/.claude/skills/run-the-starter-locally/SKILL.md +++ b/.claude/skills/run-the-starter-locally/SKILL.md @@ -5,8 +5,8 @@ description: Boot this module's whole service graph on a developer machine — w # Running the starter locally -The graph is vault → store → cache → telemetry → accounts → auth-gateway → -frontend, plus marketing. One command boots all of it with a fake-auth fixture: +The graph is vault → store → cache → accounts → auth-gateway → frontend, plus +marketing. One command boots all of it with a fake-auth fixture: ```bash codefly run service --fixture dev-admin @@ -18,6 +18,13 @@ codefly run service --fixture dev-admin - No TTY (CI, pipes, MCP) auto-enables `--headless`. - Docker must be running. `codefly doctor` checks prerequisites; `codefly clear` reaps stray processes and containers between runs. +- A local run has no cell collector, and the module ships none. The `observability` + group (`configurations/local/observability.env`) says so — `TELEMETRY_STATE=absent` + with a reason — and accounts and auth-gateway log the reason once, send traces to + wool's stdout tracer and export no metrics. They refuse to start, naming + `TELEMETRY_STATE`, when that group did not arrive: a composing workspace that + overrode it without the keys, or a `local-dogfood` run without its copied + `observability.env`. For a real external identity provider and the production-grade provider stack, use the `local-dogfood` environment and its configuration groups: diff --git a/AGENTS.md b/AGENTS.md index f4c2e0bb6..a285ee2f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ in `.claude/skills/`. See [Where the depth lives](#where-the-depth-lives). `saas-starter` is a Codefly **module**: a multi-tenant SaaS backend (three-layer authorization, Postgres RLS, RBAC, impersonation, audit), an authenticated Next.js product, a separately deployable marketing site, and the -supporting cache/store/vault/telemetry services. It is published as an +supporting cache/store/vault services. It is published as an immutable module package that downstream workspaces **compose** (never fork). - Architecture, service graph, and capability ownership: [MODULE.md](./MODULE.md) @@ -156,7 +156,7 @@ Depth sits with the code that owns it: | File | Covers | | --- | --- | -| [module/AGENTS.md](./module/AGENTS.md) | the shipped tree: generated vs authored files, the six Go modules and how to test each, configuration groups | +| [module/AGENTS.md](./module/AGENTS.md) | the shipped tree: generated vs authored files, the five Go modules and how to test each, configuration groups | | [module/deployment/AGENTS.md](./module/deployment/AGENTS.md) | the manifests as the model, `spec.deployment`, and moving an agent version | | [module/services/auth-gateway/AGENTS.md](./module/services/auth-gateway/AGENTS.md) | upstream registration, the composed-module REST prefix, the credentials both take | | [module/services/accounts/AGENTS.md](./module/services/accounts/AGENTS.md) | the durable registry record, module principals, minting a module Work Context, mesh reachability | @@ -183,7 +183,7 @@ Skills in `.claude/skills/`, loaded when the task calls for them: such a list with a count: nothing enforces one, so two changes that each add a step and each bump the same number merge cleanly into a total that is silently wrong. -- Go suites live in six independent Go modules with no `go.work` +- Go suites live in five independent Go modules with no `go.work` ([module/AGENTS.md](./module/AGENTS.md#go-suites)). - Vulnerability policy: the complete audit runs non-blocking so vendor-image findings stay in the evidence report, while a separate fail-closed step enforces diff --git a/CLAIM_INVENTORY.md b/CLAIM_INVENTORY.md index 182d441b9..5d4c9a1a0 100644 --- a/CLAIM_INVENTORY.md +++ b/CLAIM_INVENTORY.md @@ -69,7 +69,7 @@ must not be inferred from a row here. See | The documented repository-specific gate list is the enforced one | RELEASE_GATES.md § Repository-specific gates; AGENTS.md | `release-gates.test.mjs` compares the table and both prose counts against `REQUIRED_GATES` | implemented | #541 | | The complete audit runs `--fail-on-vuln=false`; a separate step enforces first-party findings fail-closed | RELEASE_GATES.md § Vulnerability policy | `.github/workflows/ci.yml` `codefly-supply-chain` | configured | Codefly + this repo | | No artifact-writing job runs unless every mandatory gate actually succeeded | RELEASE_GATES.md § Publication gating | `scripts/ci/release-gates.mjs check` + `decide`, `release-gates.test.mjs` | implemented (#535) | this repo | -| Root `go test ./...` does not cover nested service modules | RELEASE_GATES.md, AGENTS.md | six independent `go.mod` files; there is no `go.work` | implemented (documented) | #541 | +| Root `go test ./...` does not cover nested service modules | RELEASE_GATES.md, AGENTS.md | independent `go.mod` files (the root, `module/tools`, one per Go service); there is no `go.work` | implemented (documented) | #541 | | `main` here is the only default branch in the organization merging through a queue, and `secure-saas-platform` the only other requiring any check | RELEASE_GATES.md § The fleet beyond this repository | `scripts/ci/release-gates.mjs fleet` — operator-run: reading branch configuration needs `Administration: read`, which no workflow token can hold, so no gate can carry this | reviewed narrative | #617 | ## Public registry contract diff --git a/DOGFOODING.md b/DOGFOODING.md index 5869f606d..f6dc63229 100644 --- a/DOGFOODING.md +++ b/DOGFOODING.md @@ -24,7 +24,7 @@ cd ~/Development/deus/codefly/module-saas-starter codefly run service --fixture dev-admin ``` -Expected graph: `vault + store + cache + telemetry → accounts → +Expected graph: `vault + store + cache → accounts → auth-gateway → frontend` (plus marketing). Independent services may start concurrently. If any service hangs at "waiting for ready", check that step's `--debug` output. @@ -219,7 +219,7 @@ the same Codefly-managed graph. For each of `email` (Resend), `configurations/local-dogfood/.env.example` and `.secret.env.example` to their Git-ignored names and fill them in ([LOCAL_DOGFOODING.md](./LOCAL_DOGFOODING.md#configure-providers-through-codefly)). -Keep `observability.env.example` as it is for local (`debug`) telemetry. +Keep `observability.env.example` as it is for a local run with no collector (`TELEMETRY_STATE=absent`). Turnstile's published test keys drive the three abuse-protection cases: | Case | `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | `TURNSTILE_SECRET_KEY` | @@ -239,7 +239,7 @@ codefly run service --env local-dogfood - [ ] Grant analytics consent, navigate through onboarding, and verify bounded browser plus durable backend events in PostHog. - [ ] Withdraw analytics consent or log out. Browser capture stops immediately and identity resets. - [ ] Trigger controlled browser and backend errors. Sentry correlates release and environment without creating performance transactions. -- [ ] With the `debug` exporter, exercise login/onboarding and observe trace/metric/log summaries from the in-graph telemetry service. +- [ ] With `TELEMETRY_STATE=absent`, check that accounts and auth-gateway log the absent reason once at startup, then exercise login/onboarding and observe wool's stdout trace output (metrics are not exported; logs are on stdout). Remove `TELEMETRY_STATE` and confirm both services refuse to start, naming it. - [ ] Switch Turnstile to the **fail** keys. Registration and waitlist submission fail without database writes. - [ ] Switch Turnstile to the **replay** keys. The first deterministic verification follows Cloudflare's fixture behavior; replay rejection leaves state unchanged. diff --git a/LOCAL_DOGFOODING.md b/LOCAL_DOGFOODING.md index d2c758ccb..a08cab495 100644 --- a/LOCAL_DOGFOODING.md +++ b/LOCAL_DOGFOODING.md @@ -11,7 +11,7 @@ Two Codefly inputs are intentionally independent: | Input | Authority | Purpose | |---|---|---| -| `--env local-dogfood` | `configurations/local-dogfood/*` | Select independently configured identity, billing, email, analytics, error, telemetry, and abuse adapters | +| `--env local-dogfood` | `configurations/local-dogfood/*` | Select independently configured identity, billing, email, analytics, error, and abuse adapters, and the telemetry destination | | optional `--fixture ` | `module/fixtures/.yaml` | Seed a useful starting product state | A fixture never changes the identity provider. The ordinary `local` @@ -33,8 +33,8 @@ production/background fallback; a local port never belongs in product config. ## Configure providers through Codefly WorkOS is the identity adapter. The production-grade dogfood stack also -includes Stripe, Resend, PostHog, Sentry, the in-graph OpenTelemetry gateway, -and Cloudflare Turnstile. Each one is a Codefly configuration group on this +includes Stripe, Resend, PostHog, Sentry, the OpenTelemetry collector +destination, and Cloudflare Turnstile. Each one is a Codefly configuration group on this profile: copy the committed `configurations/local-dogfood/.env.example` (and `.secret.env.example`, where there is one) to the Git-ignored `.env` / `.secret.env`, and fill in the values. Every group is @@ -50,19 +50,21 @@ plugin, which validates the account, observes the webhook and projects the | `email` | Resend | | `product-analytics` | PostHog | | `error-tracking` | Sentry | -| `observability` | the in-graph OpenTelemetry gateway | +| `observability` | the OpenTelemetry collector the cell provides, or none | | `abuse-protection` | Cloudflare Turnstile | Validate the result with `codefly doctor workspace --env local-dogfood`. -`observability` is the one group this profile cannot skip. The `telemetry` -service declares it as a workspace-configuration-dependency, and on -`local-dogfood` the real `observability.env` / `observability.secret.env` are -Git-ignored, so there is no committed default to fall back on. Codefly skips a -declared group whose files are absent rather than failing, and the collector -refuses to start without an explicit `OBSERVABILITY_EXPORTER` — it will not -silently downgrade itself to `debug` and drop every span. Copy -`observability.env.example` as it is to keep telemetry local. +`observability` is the one group this profile cannot skip. `accounts` and +`auth-gateway` declare it as a workspace-configuration-dependency, and on +`local-dogfood` the real `observability.env` is Git-ignored, so there is no +committed default to fall back on. Codefly skips a declared group whose files are +absent rather than failing, and both services refuse to start without an explicit +`TELEMETRY_STATE` — a group that did not arrive is not a cell without a collector, +and they will not guess. Copy `observability.env.example` as it is to run with +none: `TELEMETRY_STATE=absent` and its reason, traces on wool's stdout tracer, +metrics not exported. To export to a collector this machine can reach, set +`TELEMETRY_STATE=available` and `OTEL_EXPORTER_OTLP_ENDPOINT` as the example says. `email` is required because this profile is not the `local` environment, and accounts refuses to start there without an explicit `EMAIL_PROVIDER`: `resend` diff --git a/MODULE.md b/MODULE.md index f5757d539..e4ce70545 100644 --- a/MODULE.md +++ b/MODULE.md @@ -100,7 +100,7 @@ capability; it does not claim that every planned provider is released yet. | Product analytics | PostHog | The `product-analytics@1` browser/server APIs may capture registered events and perform consent-gated identify, alias, organization grouping, and privacy suppression. An event may carry a variant already evaluated by Unleash for experiment analysis. | Flag definition or evaluation, exception capture, and traces, metrics, or logs | | Session replay | PostHog | A browser-only recorder may start after separate replay consent and redaction policy resolve to allow. It has no server SDK or implicit analytics-consent fallback. | Flags, errors, and APM signals | | Error tracking | Sentry | Browser/server exception events, release/environment tags, source-map upload, and the error-issue workflow. Provisioning credentials remain build/provider-only. | Performance transactions, profiles, replay, logs, metrics, and feature flags | -| APM traces, metrics, and logs | SigNoz | Applications use OpenTelemetry SDKs and standard OTLP configuration through the in-graph collector. SigNoz receives those signals as the OTLP backend. | Product analytics, replay, feature flags, and the Sentry error-issue contract | +| APM traces, metrics, and logs | SigNoz | Applications use OpenTelemetry SDKs and export traces and metrics over OTLP/gRPC to the cell's collector, whose address the platform delivers in the `observability` configuration group (or the group says the cell has none); logs go to stdout. SigNoz receives those signals as the OTLP backend. | Product analytics, replay, feature flags, and the Sentry error-issue contract | Provider manifests are allowlists. `provider-posthog` may project only `product-analytics@1`; its browser initialization must disable flag remote @@ -111,8 +111,9 @@ Sentry trace sampling at zero and installs no Sentry tracing integration. `provider-signoz`, if API qualification succeeds, may manage exactly owned dashboards and alerts but projects no application runtime configuration. In particular, dashboard provisioning is not an application telemetry contract: -application OTLP endpoints remain instrumentation configuration resolved -through the telemetry service. +the application OTLP endpoint remains instrumentation configuration, delivered +as `OTEL_EXPORTER_OTLP_ENDPOINT` in the `observability` group when the cell has a +collector. The starter keeps the capability configurations independent. Selecting PostHog never selects flags, errors, or observability; selecting Sentry never @@ -585,7 +586,7 @@ names the source repository and a package version rather than a path: modules: - name: saas-starter source: codefly-dev/module-saas-starter - version: "0.1.0" + version: "0.2.0" ``` Resolution fails closed, so the workspace must also say who it trusts to have diff --git a/README.md b/README.md index 6bb76e61f..df0633c3e 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ not this list, is the enumeration. Beyond the API, the module ships the authenticated product frontend (whose routes and navigation modules contribute to), a separately deployable public -marketing site, and the cache, store, vault, telemetry, and gateway services +marketing site, and the cache, store, vault, and gateway services the above runs on. ## What it does not own diff --git a/RELEASE_GATES.md b/RELEASE_GATES.md index fdf3509ef..412f1be86 100644 --- a/RELEASE_GATES.md +++ b/RELEASE_GATES.md @@ -193,7 +193,7 @@ Agent-generated `module/services/*/builder/Dockerfile` recipes must **not** be configured in Dependabot: agents replace them before building. The coverage gate rejects those entries. `scripts/ci/build-images.json` records the expected build images and the owning agent source for Go, -Next.js and the Postgres migration builder (including telemetry's generated Go +Next.js and the Postgres migration builder (including the Go services' generated recipe, which is not checked in). The weekly `build-images.yml` monitor compares registry digests for the effective @@ -441,8 +441,8 @@ reverse, fails the `release-contract` job. | `scoped-pools` | the authority of the pools the store's logins reach: the scoped reader, the scoped writer and the request pool, judged against a native PostgreSQL the suite provisions and widens one grant at a time | `python3 scripts/qualify-scoped-pools.py --postgres-bin "$(pg_config --bindir)"`, which runs `go test -race ./qualification/scopedpools` and fails unless every declared test in that package ran and passed | Two of those `go test ./...` invocations are worth reading carefully. This -repository holds **six independent Go modules** — root, `module/tools`, and one -per Go service — with no `go.work`, so `go test ./...` covers only the module it +repository holds **five independent Go modules** — root, `module/tools`, and one +per Go service (`accounts`, `auth-gateway`, `store`) — with no `go.work`, so `go test ./...` covers only the module it is run in. The `sdk-boundary` invocation runs in the root module (the module agent, the host, the generated reference composition) and the `module-package` one runs in `module/tools`; neither compiles, let alone tests, diff --git a/module/AGENTS.md b/module/AGENTS.md index f89e7041a..475eafbf1 100644 --- a/module/AGENTS.md +++ b/module/AGENTS.md @@ -13,7 +13,7 @@ Read the repository-root `AGENTS.md` first for ownership, boundaries and the behavioural rules (in a consumer, read your own). [SERVICE_CATALOG.md](./SERVICE_CATALOG.md) and [DEPLOYMENT_TOPOLOGY.md](./DEPLOYMENT_TOPOLOGY.md) describe the graph; -[services/README.md](./services/README.md) introduces the eight services. +[services/README.md](./services/README.md) introduces the seven services. ## Generated files are never edited by hand @@ -60,11 +60,13 @@ adding a REST-enabled RPC means editing it. Do not go looking for a generator. ## Go suites Every Go service here carries **its own `go.mod`** — `accounts`, `auth-gateway`, -`store`, `telemetry` — and `tools` is another, with **no `go.work`** tying them -together. So `go test ./...` only ever covers the module you run it in, and a -service's suite is outside whatever you run from the tree root. The canonical -repository adds its own root module (the module agent, the host, and the generated -reference composition), for six in total; a consumer's root module is its own. +`store` — and `tools` is another, with **no `go.work`** tying them together. So +`go test ./...` only ever covers the module you run it in, and a service's suite +is outside whatever you run from the tree root. The canonical repository adds its +own root module (the module agent, the host, and the generated reference +composition), for five in total; a consumer's root module is its own. +`libraries/source-read-sdk/go` and `qualification/module-authority` are two more +modules, outside this tree, that CI exercises in steps of their own. To exercise a service, run its suite from its own directory — its DB-backed suites need Codefly and Docker — or let `codefly ci run` do it. diff --git a/module/DEPLOYMENT_TOPOLOGY.md b/module/DEPLOYMENT_TOPOLOGY.md index 90a39df12..569713648 100644 --- a/module/DEPLOYMENT_TOPOLOGY.md +++ b/module/DEPLOYMENT_TOPOLOGY.md @@ -21,8 +21,8 @@ anywhere else. | `services/accounts/code/pkg/cataloggen/testdata/mesh-policy.golden.yaml` | Test-only mesh-policy golden (STRICT mTLS + the internal-authority and internal-HTTP AuthorizationPolicies + waypoint); mirrors those resources from the per-environment GitOps mesh baseline, not the whole of it — the namespace `default-deny` and the L4 internal policies are rendered there only. | | `services/accounts/code/pkg/cataloggen/deployment_topology.go` | Strict compiler, semantic validator, and renderers. | -The normalized inventory currently contains eight services, twelve endpoints, -eight dependency edges, four module-interface endpoints, and four explicit +The normalized inventory currently contains seven services, twelve endpoints, +six dependency edges, six module-interface endpoints, and three explicit public-egress grants (`deployment_topology_test.go` pins the counts). The accounts descriptor catalog is an input: if its RPCs use gRPC, Connect, or REST without a corresponding accounts endpoint, generation fails. @@ -33,7 +33,6 @@ generation fails. | --- | --- | --- | | `accounts` | `cache/read`, `cache/write` | TCP 6379 | | `accounts` | `store/tcp` | TCP 5432 | -| `accounts` | `telemetry/grpc` | Codefly-assigned OTLP gRPC port | | `accounts` | `vault/http` | TCP 8200 | | `auth-gateway` | `accounts/connect`, `accounts/rest`, `accounts/grpc` | TCP 8080, 9090 | | `auth-gateway` | `cache/write` | TCP 6379 | @@ -42,21 +41,21 @@ generation fails. The Codefly module interface exposes the public `frontend/http` and `marketing/http` endpoints; the auth-gateway gRPC ext-authz endpoint has module visibility. Istio routes apex/`www`/docs hosts to `marketing/http` and `app` to -`frontend/http`. Accounts, frontend, marketing, and telemetry may reach -public IP space only over TCP 443. The public rules exclude private, loopback, +`frontend/http`. Accounts, frontend, and marketing may reach +public IP space only over TCP 443; `auth-gateway` has no public egress. The public rules exclude private, loopback, link-local, metadata, documentation, benchmark, multicast, and other special-purpose IPv4/IPv6 ranges. Temporal's gRPC frontend and HTTP UI remain module-visible without a public ingress route. ## Network-policy model -The topology-policy golden contains 25 `NetworkPolicy` resources: +The topology-policy golden contains 18 `NetworkPolicy` resources: - one namespace-wide ingress/egress default deny; - DNS and Istio control-plane egress for all injected workloads; - Istio ingress only to the public frontend and marketing HTTP ports; - target ingress and caller egress policies for every declared dependency; -- HTTPS public egress only for accounts, frontend, marketing, and telemetry. +- HTTPS public egress only for accounts, frontend, and marketing. There is no `allow-intra-namespace` rule. Adding a service dependency or port requires changing the topology binding and reviewing both generated directions @@ -71,10 +70,14 @@ topology binding in the same change; pinned deployment schema/render validation is tracked by `P1-CI-004`. The generated Codefly dependency declarations contain exact endpoint -references. Accounts resolves `telemetry/grpc` through the SDK and passes that -exact Codefly-owned address to both tracing and metrics; no product -configuration owns a local collector port. The generated NetworkPolicy remains -the hard endpoint/port enforcement boundary. +references. The module owns no collector: accounts and auth-gateway export traces +and metrics to the cell's, whose address the platform delivers in the +`observability` configuration group (`TELEMETRY_STATE`, with +`OTEL_EXPORTER_OTLP_ENDPOINT` when the cell has a collector and +`TELEMETRY_ABSENT_REASON` when it does not), so neither service needs public +egress to reach a telemetry backend and no product configuration owns a collector +port. The generated NetworkPolicy remains the hard endpoint/port enforcement +boundary for the services the module declares. The AWS overlay replaces stateful services with managed dependencies. A pod-selector rule cannot authorize an RDS, ElastiCache, external Vault, or S3 @@ -271,7 +274,7 @@ exports, and missing descriptor-required accounts protocols. Parity tests build every artifact twice, compare all checked-in outputs, parse the generated files through Codefly's resource model, and strictly inspect all -25 NetworkPolicy golden documents. After the module generator creates the +18 NetworkPolicy golden documents. After the module generator creates the consumer-owned GitOps tree, render an environment with: ```sh diff --git a/module/FEATURES.md b/module/FEATURES.md index 7753e98bf..29566a9fc 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -49,8 +49,8 @@ the auth-gateway in front so this code path isn't reached. | Test infra | Playwright e2e against the real stack via `withDependencies` | Everything is orchestrated by Codefly: `codefly run service --fixture -dev-admin` resolves the module's service graph and brings up all eight -services—Postgres + Vault + Redis + object storage + telemetry + Accounts + +dev-admin` resolves the module's service graph and brings up its +services—Postgres + Vault + Redis + object storage + Accounts + the private auth gateway + frontend—with seed data in one command. Marketing remains a separate runtime and may be started, deployed, rolled back, or disabled without changing the authenticated product. @@ -495,8 +495,8 @@ extraction contracts. |----------------|--------|-----------------------------------------------------------------| | Structured logs| ✅ | `wool` everywhere; user/org/action context auto-attached | | Audit trail | ✅ | Separate from app logs; queryable | -| Metrics | ✅ | Job-worker OTel instruments, durable queue projections, and an in-graph OTLP gateway | -| Tracing | ✅ | Accounts resolves the Codefly collector endpoint and exports OTLP for the designated SigNoz backend | +| Metrics | ✅ | Job-worker OTel instruments, durable queue projections, and Go runtime and request metrics pushed over OTLP to the cell's collector | +| Tracing | ✅ | Accounts and auth-gateway export OTLP to the cell's collector, whose address the platform delivers in the `observability` group, for the designated SigNoz backend | | Error tracking | ✅ | Explicit fail-closed Sentry mode for server/browser errors; trace sampling is fixed at zero | | Dashboards | 🟡 | Versioned provider-neutral business dashboard pack; [SigNoz provisioning remains unsupported pending a pinned service qualification](SIGNOZ_PROVISIONING.md) | @@ -655,20 +655,29 @@ Environment variables consumed by the api: | `POSTHOG_API_HOST` | Separate PostHog management/deletion origin | | `ERROR_TRACKING_MODE` | Explicit `disabled` or `sentry`; rejects partial config | | `SENTRY_DSN` | Server Sentry DSN, required in Sentry mode | -| `OBSERVABILITY_EXPORTER` | Required in-graph collector output: `debug` or `otlphttp`; the collector refuses to start when it is unset | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | External OTLP/HTTP destination used only by the collector | -| `OTEL_EXPORTER_OTLP_HEADERS` | Secret external collector headers | +| `TELEMETRY_STATE` | `observability` group, delivered by the platform: `available` or `absent`, never empty; accounts and auth-gateway refuse to start without it | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | `observability` group: the cell collector's OTLP/gRPC address, present only when `available`; `http://` is plaintext on the wire because the mesh supplies mTLS, `https://` is TLS | +| `TELEMETRY_ABSENT_REASON` | `observability` group: why the cell has no collector, present only when `absent`; logged once at startup | | `ABUSE_PROTECTION_MODE` | Explicit `disabled` or `turnstile` | | `TURNSTILE_SECRET_KEY` | Server-only Siteverify credential | | `TURNSTILE_ALLOWED_HOSTNAMES` | Exact accepted Turnstile response hostnames | | `CODEFLY__FIXTURE` | Loads fixture YAML (e.g. `dev-admin`); FE login picker too | -When `OTEL_EXPORTER_OTLP_ENDPOINT` is configured, the accounts service and the -auth-gateway export unsampled request and Go runtime metrics through the in-graph -OpenTelemetry collector. The auth-gateway covers both its HTTP gateway and gRPC ext_authz -authorization service. Prometheus can alternatively scrape `/metrics` on each -service's private REST endpoint; neither route is a module or public interface -endpoint. +When `TELEMETRY_STATE` is `available`, the accounts service and the auth-gateway +export traces and unsampled request and Go runtime metrics over OTLP/gRPC to the +cell's collector at `OTEL_EXPORTER_OTLP_ENDPOINT`. The auth-gateway covers both its +HTTP gateway and gRPC ext_authz authorization service. When it is `absent`, they +export nothing over OTLP, log `TELEMETRY_ABSENT_REASON` once and boot; a local run +then uses wool's stdout tracer for traces. Anything else — the state missing or +unknown, `available` without an endpoint, `absent` without a reason, or both +set — refuses to start, because a group that did not arrive is not a cell without +a collector. An `https://` endpoint is refused at startup for now: wool's OTLP +tracer dials plaintext only, so it would be sent in the clear and reported as TLS. +Metrics leave by OTLP push alone — decided 2026-10-06, because the cell's +collector is the record for traces and metrics take the same path — so neither +service serves a scrape endpoint. `service.name` comes from `OTEL_SERVICE_NAME` or +`OTEL_RESOURCE_ATTRIBUTES` when the platform sets it, else the service's own +Codefly identity (`module/service`, the one wool puts on its log lines). Frontend browser configuration. The server reads each value from its Codefly group per request (`identity`, `legal`, `product-features`, `product-analytics`, `error-tracking`, diff --git a/module/PRODUCT_ANALYTICS.md b/module/PRODUCT_ANALYTICS.md index 886315b33..20798f901 100644 --- a/module/PRODUCT_ANALYTICS.md +++ b/module/PRODUCT_ANALYTICS.md @@ -200,9 +200,10 @@ active organization. ## Operational metrics, alerts, and recovery SigNoz is the single backend for application traces, metrics, and logs. The -application surface remains standard OpenTelemetry/OTLP through the in-graph -collector; an optional SigNoz provider may provision dashboards and alerts but -does not project an application SDK, endpoint, or credential. +application surface remains standard OpenTelemetry/OTLP to the cell's collector, +whose address the platform delivers in the `observability` configuration group; an +optional SigNoz provider may provision dashboards and alerts but does not project +an application SDK, endpoint, or credential. Job workers emit OpenTelemetry `saas.jobs.polls`, `claimed`, `active`, `completed`, and `duration` instruments. Labels are limited to queue plus diff --git a/module/SIGNOZ_PROVISIONING.md b/module/SIGNOZ_PROVISIONING.md index f1bdb6f5f..e6fa41412 100644 --- a/module/SIGNOZ_PROVISIONING.md +++ b/module/SIGNOZ_PROVISIONING.md @@ -11,11 +11,12 @@ deployment against which to prove the lifecycle. Creating a provider before that boundary exists would turn an untested upstream API into a claimed Codefly contract. -This decision does not affect application telemetry. Applications continue to -send OTLP to the in-graph collector endpoint resolved through Codefly; they do -not read or hard-code that address. `OTEL_EXPORTER_OTLP_ENDPOINT` remains only -the collector's external OTLP/HTTP destination. Dashboard provisioning is not -an application runtime capability. +This decision does not affect application telemetry. Applications export +OTLP/gRPC to the cell's collector, at the address the platform delivers as +`OTEL_EXPORTER_OTLP_ENDPOINT` in the `observability` configuration group (or the +group says the cell has none); they do not hard-code it, and the module ships no +collector of its own. Dashboard provisioning is not an application runtime +capability. ## Qualification result diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index 96565e7e1..5d35b09f0 100644 --- a/module/contracts/api/catalog.codefly.json +++ b/module/contracts/api/catalog.codefly.json @@ -1,7 +1,7 @@ { "schema": "codefly/module-api-contracts/v1", "package": "codefly/saas-starter", - "version": "0.1.0", + "version": "0.2.0", "endpoints": [ { "service": "accounts", diff --git a/module/contracts/reference/module.codefly.yaml b/module/contracts/reference/module.codefly.yaml index 3cb683573..c5bb83ce7 100644 --- a/module/contracts/reference/module.codefly.yaml +++ b/module/contracts/reference/module.codefly.yaml @@ -2,7 +2,7 @@ kind: composed-module name: reference-product base: id: codefly/saas-starter - version: ">=0.1.0 <0.2.0" + version: ">=0.2.0 <0.3.0" contributions: frontend: - path: frontend diff --git a/module/deployment/generated/service-topology.json b/module/deployment/generated/service-topology.json index b40e3cafd..6164033d8 100644 --- a/module/deployment/generated/service-topology.json +++ b/module/deployment/generated/service-topology.json @@ -44,12 +44,6 @@ "tcp" ] }, - { - "service": "telemetry", - "endpoints": [ - "grpc" - ] - }, { "service": "vault", "endpoints": [ @@ -88,12 +82,6 @@ "endpoints": [ "write" ] - }, - { - "service": "telemetry", - "endpoints": [ - "grpc" - ] } ] }, @@ -155,17 +143,6 @@ } ] }, - { - "name": "telemetry", - "endpoints": [ - { - "name": "grpc", - "api": "CODEFLY_API_GRPC", - "visibility": "ENDPOINT_VISIBILITY_PRIVATE", - "port": 9090 - } - ] - }, { "name": "vault", "endpoints": [ @@ -228,12 +205,6 @@ "ports": [ 443 ] - }, - { - "service": "telemetry", - "ports": [ - 443 - ] } ] } diff --git a/module/docs/composition-v2-migration.md b/module/docs/composition-v2-migration.md index 9a376f607..e61b14c03 100644 --- a/module/docs/composition-v2-migration.md +++ b/module/docs/composition-v2-migration.md @@ -11,7 +11,7 @@ contract. The copied tree is removed only after the v1 and v2 projections produc the same service catalog, endpoint graph, frontend inventory, permissions, settings, fixtures, and runtime smoke results. -The `0.1.x` package line supports migration from Starter `0.0.32` and later. Earlier +The `0.1.x` and `0.2.x` package lines support migration from Starter `0.0.32` and later. Earlier copies must first update to a release the package line supports. The v2 descriptor points directly at contribution sources; Starter does not @@ -23,7 +23,7 @@ kind: composed-module name: my-product base: id: codefly/saas-starter - version: ">=0.1.0 <0.2.0" + version: ">=0.2.0 <0.3.0" contributions: frontend: - {path: frontend, export: productPlugin} diff --git a/module/module.codefly.yaml b/module/module.codefly.yaml index a67b091fe..1140227aa 100644 --- a/module/module.codefly.yaml +++ b/module/module.codefly.yaml @@ -29,5 +29,4 @@ services: - name: frontend - name: marketing - name: store - - name: telemetry - name: vault diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index 9f1fb55a7..452f16bad 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -1,7 +1,7 @@ schema: codefly/module-package/v2 kind: module-package id: codefly/saas-starter -version: 0.1.0 +version: 0.2.0 minimum-codefly-version: ">=0.3.32 <1.0.0" artifact-roots: - contracts @@ -40,8 +40,6 @@ services: endpoints: [http] - name: store endpoints: [tcp] - - name: telemetry - endpoints: [grpc] - name: vault endpoints: [http] fixtures: @@ -100,6 +98,7 @@ migrations: breaking-changes: - Consumers resolve the immutable package into a disposable projection instead of editing Starter-owned files. - "Audit event types are namespaced under `saas.*` (`auth.login` -> `saas.auth.login`). The wire value of `audit_events.event_type`, the `X-Webhook-Event` header, and webhook routing keys all change; migration 116 rewrites stored history and existing subscriptions in place, so a live subscriber keeps firing, but a consumer that re-creates a subscription from a hardcoded pre-namespace name, or emits one through `ModuleEmitAuditEvent`, is now rejected. No alias layer." + - "The `telemetry` service is removed: the module no longer ships an OTLP gateway, and the cell's collector takes its place. `accounts` and `auth-gateway` read where it is from the `observability` configuration group — `TELEMETRY_STATE` is `available` with `OTEL_EXPORTER_OTLP_ENDPOINT` (an `http://` endpoint is plaintext on the wire because the mesh supplies mTLS; `https://` is TLS), or `absent` with `TELEMETRY_ABSENT_REASON` — and refuse to start when the group does not say which. `OBSERVABILITY_EXPORTER` and `OTEL_EXPORTER_OTLP_HEADERS` no longer mean anything, the `/metrics` scrape endpoint of both services is gone (metrics leave by OTLP push only), and `service.name` is no longer fixed in code: `OTEL_SERVICE_NAME` or `OTEL_RESOURCE_ATTRIBUTES` sets it, else the service's own Codefly identity. A consumer that depended on the `telemetry` service, or that overrides the `observability` group, must move to the keys above." reserved-namespaces: - "@codefly/saas-" - saas diff --git a/module/services/README.md b/module/services/README.md index 09ba17c49..383ea5d0f 100644 --- a/module/services/README.md +++ b/module/services/README.md @@ -1,6 +1,6 @@ # Module services -The module contains eight first-class Codefly services. `marketing` and +The module contains seven first-class Codefly services. `marketing` and `frontend` are separate Next.js applications: marketing owns public apex/`www` content, while frontend remains the authenticated product behind `auth-gateway`. diff --git a/module/services/accounts/TESTING.md b/module/services/accounts/TESTING.md index 3456af2ab..bd83c7f5a 100644 --- a/module/services/accounts/TESTING.md +++ b/module/services/accounts/TESTING.md @@ -23,8 +23,8 @@ The default `go test ./...` and `codefly ci run` retain all existing default tes The pure command is an additional fast check, not a replacement release gate. Database targets also retain their package's pure tests. They serialize setup, execution and teardown through the existing package lock. Business tests use -Vault for hashing and encrypted credentials. None of them uses telemetry, and -only the Redis target starts the `cache` service: it proves the org-membership +Vault for hashing and encrypted credentials. Only the Redis target starts the +`cache` service: it proves the org-membership cache stack across two instances and the Redis-held revocation, nonce and rate-limit state against the real server. Dependency exclusions prevent every other service from building or starting. diff --git a/module/services/accounts/code/external/saas-starter/telemetry/saas-starter_telemetry_grpc.pb.go b/module/services/accounts/code/external/saas-starter/telemetry/saas-starter_telemetry_grpc.pb.go deleted file mode 100644 index 689bf2157..000000000 --- a/module/services/accounts/code/external/saas-starter/telemetry/saas-starter_telemetry_grpc.pb.go +++ /dev/null @@ -1,60 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: saas-starter_telemetry_grpc.proto - -package telemetry - -import ( - reflect "reflect" - unsafe "unsafe" - - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -var File_saas_starter_telemetry_grpc_proto protoreflect.FileDescriptor - -const file_saas_starter_telemetry_grpc_proto_rawDesc = "" + - "\n" + - "!saas-starter_telemetry_grpc.protoBhB\x1dSaasStarterTelemetryGrpcProtoP\x01ZEgithub.com/codefly-dev/cli/pkg/builder/clients/saas-starter/telemetry" - -var file_saas_starter_telemetry_grpc_proto_goTypes = []any{} -var file_saas_starter_telemetry_grpc_proto_depIdxs = []int32{ - 0, // [0:0] is the sub-list for method output_type - 0, // [0:0] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name -} - -func init() { file_saas_starter_telemetry_grpc_proto_init() } -func file_saas_starter_telemetry_grpc_proto_init() { - if File_saas_starter_telemetry_grpc_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_starter_telemetry_grpc_proto_rawDesc), len(file_saas_starter_telemetry_grpc_proto_rawDesc)), - NumEnums: 0, - NumMessages: 0, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_saas_starter_telemetry_grpc_proto_goTypes, - DependencyIndexes: file_saas_starter_telemetry_grpc_proto_depIdxs, - }.Build() - File_saas_starter_telemetry_grpc_proto = out.File - file_saas_starter_telemetry_grpc_proto_goTypes = nil - file_saas_starter_telemetry_grpc_proto_depIdxs = nil -} diff --git a/module/services/accounts/code/pkg/auth/pg/session_store_test.go b/module/services/accounts/code/pkg/auth/pg/session_store_test.go index ea72e651a..a85061641 100644 --- a/module/services/accounts/code/pkg/auth/pg/session_store_test.go +++ b/module/services/accounts/code/pkg/auth/pg/session_store_test.go @@ -64,7 +64,7 @@ func runSessionStoreTests(m *testing.M) int { deps, err := sdk.WithDependencies(ctx, sdk.WithDebug(), sdk.WithSharedControlChannel(), - sdk.WithExcludedDependencies("cache", "vault", "telemetry"), + sdk.WithExcludedDependencies("cache", "vault"), sdk.WithNamingScope("pgauth-test"), sdk.WithTimeout(120*time.Second), sdk.WithSilence("store"), diff --git a/module/services/accounts/code/pkg/billing/pg/store_test.go b/module/services/accounts/code/pkg/billing/pg/store_test.go index 549ea891a..db2686f5c 100644 --- a/module/services/accounts/code/pkg/billing/pg/store_test.go +++ b/module/services/accounts/code/pkg/billing/pg/store_test.go @@ -45,7 +45,7 @@ func runBillingStoreTests(m *testing.M) int { deps, err := sdk.WithDependencies(ctx, sdk.WithDebug(), sdk.WithSharedControlChannel(), - sdk.WithExcludedDependencies("cache", "vault", "telemetry"), + sdk.WithExcludedDependencies("cache", "vault"), sdk.WithNamingScope("pgbilling-test"), sdk.WithTimeout(120*time.Second), sdk.WithSilence("store"), diff --git a/module/services/accounts/code/pkg/business/service_test.go b/module/services/accounts/code/pkg/business/service_test.go index 800624ed8..b792dbabd 100644 --- a/module/services/accounts/code/pkg/business/service_test.go +++ b/module/services/accounts/code/pkg/business/service_test.go @@ -109,7 +109,7 @@ func runBusinessTests(m *testing.M) int { deps, err := sdk.WithDependencies(ctx, sdk.WithDebug(), sdk.WithSharedControlChannel(), - sdk.WithExcludedDependencies("cache", "telemetry"), + sdk.WithExcludedDependencies("cache"), // Keep this package-owned integration stack distinct from the outer // service runtime and the other package TestMain stacks. The SDK gives // each short-lived flow temporary host ports; the scope also isolates diff --git a/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go b/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go index 2e37d274c..50394ce47 100644 --- a/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go +++ b/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go @@ -71,16 +71,16 @@ func TestDeploymentTopologyIsDeterministicAndCurrent(t *testing.T) { require.Equal(t, string(readFixture(t, "testdata/network-policy.golden.yaml")), string(first.NetworkPolicy), "run: go generate ./pkg/cataloggen") require.Equal(t, string(readFixture(t, "testdata/mesh-policy.golden.yaml")), string(first.MeshPolicy), "run: go generate ./pkg/cataloggen") - require.Len(t, first.Catalog.GetServices(), 8) + require.Len(t, first.Catalog.GetServices(), 7) require.Len(t, first.Catalog.GetInterfaceEndpoints(), 6) - require.Len(t, first.Catalog.GetPublicEgress(), 4) + require.Len(t, first.Catalog.GetPublicEgress(), 3) endpointCount, dependencyCount := 0, 0 for _, service := range first.Catalog.GetServices() { endpointCount += len(service.GetEndpoints()) dependencyCount += len(service.GetDependencies()) } - require.Equal(t, 13, endpointCount) - require.Equal(t, 8, dependencyCount) + require.Equal(t, 12, endpointCount) + require.Equal(t, 6, dependencyCount) // The accounts REST surface is reachable only through the gateway; the // gateway's REST surface is module-visible because composed modules and // solutions use it (Work Context minting, solution registration) and the @@ -90,7 +90,6 @@ func TestDeploymentTopologyIsDeterministicAndCurrent(t *testing.T) { "auth-gateway": catalogv1.EndpointVisibility_ENDPOINT_VISIBILITY_MODULE, } privateREST := map[string]bool{"accounts": false, "auth-gateway": false} - authGatewayTelemetry := false for _, service := range first.Catalog.GetServices() { if _, ok := privateREST[service.GetName()]; !ok { continue @@ -101,17 +100,23 @@ func TestDeploymentTopologyIsDeterministicAndCurrent(t *testing.T) { privateREST[service.GetName()] = true } } - if service.GetName() == "auth-gateway" { - for _, dependency := range service.GetDependencies() { - if dependency.GetService() == "telemetry" { - require.Equal(t, []string{"grpc"}, dependency.GetEndpoints()) - authGatewayTelemetry = true - } - } - } } require.Equal(t, map[string]bool{"accounts": true, "auth-gateway": true}, privateREST) - require.True(t, authGatewayTelemetry) + // The module owns no collector: telemetry is exported to the cell's, which is + // reached in-mesh, so no service has a telemetry dependency and the gateway, + // whose job is to be the perimeter, has no public egress to reach a backend. + for _, service := range first.Catalog.GetServices() { + require.NotEqual(t, "telemetry", service.GetName()) + for _, dependency := range service.GetDependencies() { + require.NotEqual(t, "telemetry", dependency.GetService(), "%s depends on a telemetry service", service.GetName()) + } + } + egress := map[string]bool{} + for _, grant := range first.Catalog.GetPublicEgress() { + egress[grant.GetService()] = true + } + require.Equal(t, map[string]bool{"accounts": true, "frontend": true, "marketing": true}, egress) + require.False(t, egress["auth-gateway"], "auth-gateway must have no public egress") accountsConnectExposed, accountsAuthorityExposed, gatewayRESTExposed := false, false, false for _, endpoint := range first.Catalog.GetInterfaceEndpoints() { if endpoint.GetService() == "accounts" && endpoint.GetEndpoint() == "connect" { @@ -131,16 +136,18 @@ func TestDeploymentTopologyIsDeterministicAndCurrent(t *testing.T) { require.True(t, accountsConnectExposed) require.True(t, accountsAuthorityExposed) require.True(t, gatewayRESTExposed) - require.Equal(t, 20, strings.Count(string(first.NetworkPolicy), "\nkind: NetworkPolicy\n")) + require.Equal(t, 18, strings.Count(string(first.NetworkPolicy), "\nkind: NetworkPolicy\n")) require.NotContains(t, string(first.NetworkPolicy), "allow-intra-namespace") for _, name := range []string{ "allow-accounts-from-dependents", "allow-auth-gateway-from-dependents", "allow-auth-gateway-to-dependencies", "allow-frontend-to-dependencies", "allow-store-from-bootstrap", "allow-store-bootstrap-to-store", - "allow-frontend-public-egress", "allow-marketing-public-egress", "allow-telemetry-from-dependents", - "allow-telemetry-public-egress", "allow-istio-ingress-to-marketing", "allow-istio-ingress-to-frontend", + "allow-frontend-public-egress", "allow-marketing-public-egress", + "allow-istio-ingress-to-marketing", "allow-istio-ingress-to-frontend", } { require.Contains(t, string(first.NetworkPolicy), "name: "+name) } + require.NotContains(t, string(first.NetworkPolicy), "telemetry") + require.NotContains(t, string(first.NetworkPolicy), "name: allow-auth-gateway-public-egress") require.NotContains(t, string(first.NetworkPolicy), "name: allow-istio-ingress-to-auth-gateway") require.Equal(t, 2, strings.Count(string(first.NetworkPolicy), "codefly.dev/bootstrap-service: store")) require.Contains(t, string(first.NetworkPolicy), "192.175.48.0/24") @@ -165,11 +172,11 @@ func TestDeploymentSpecIsStrictAndComplete(t *testing.T) { require.ErrorContains(t, err, `names unknown endpoint "ghost"`) _, err = cataloggen.BuildDeploymentArtifacts(serviceCatalog, - withService(t, documents, "telemetry", " deployment:\n", " deployment-typo:\n")) + withService(t, documents, "vault", " deployment:\n", " deployment-typo:\n")) require.ErrorContains(t, err, "has no spec.deployment block") _, err = cataloggen.BuildDeploymentArtifacts(serviceCatalog, - withServiceDocument(documents, "ghost", documents.Services["telemetry"])) + withServiceDocument(documents, "ghost", documents.Services["vault"])) require.ErrorContains(t, err, "not declared by module.codefly.yaml") _, err = cataloggen.BuildDeploymentArtifacts(serviceCatalog, @@ -184,7 +191,7 @@ func TestGeneratedCodeflyAndNetworkManifestsParseStrictly(t *testing.T) { require.NoError(t, loadedModule.ValidateInterface(ctx)) loadedServices, err := loadedModule.LoadServices(ctx) require.NoError(t, err) - require.Len(t, loadedServices, 8) + require.Len(t, loadedServices, 7) moduleDocument := readFixture(t, "../../../../../module.codefly.yaml") var moduleEntry struct { @@ -196,7 +203,7 @@ func TestGeneratedCodeflyAndNetworkManifestsParseStrictly(t *testing.T) { require.NoError(t, yaml.Unmarshal(moduleDocument, &module)) _, err = module.Proto(ctx) require.NoError(t, err) - require.Len(t, module.ServiceReferences, 8) + require.Len(t, module.ServiceReferences, 7) for _, reference := range module.ServiceReferences { document := readFixture(t, filepath.Join("../../../../../services", reference.Name, "service.codefly.yaml")) @@ -238,14 +245,14 @@ func TestGeneratedCodeflyAndNetworkManifestsParseStrictly(t *testing.T) { require.False(t, names[document.Metadata.Name], "duplicate NetworkPolicy %s", document.Metadata.Name) names[document.Metadata.Name] = true } - require.Len(t, names, 20) + require.Len(t, names, 18) require.True(t, names["allow-istio-ingress-to-marketing"]) require.True(t, names["allow-istio-ingress-to-frontend"]) require.False(t, names["allow-istio-ingress-to-auth-gateway"]) require.True(t, names["allow-store-from-bootstrap"]) require.True(t, names["allow-store-bootstrap-to-store"]) - require.True(t, names["allow-telemetry-from-dependents"]) - require.True(t, names["allow-telemetry-public-egress"]) + require.False(t, names["allow-telemetry-from-dependents"]) + require.False(t, names["allow-telemetry-public-egress"]) } func TestGeneratedMeshPolicyGatesInternalAuthorityByCallerIdentity(t *testing.T) { @@ -727,7 +734,7 @@ func TestDeploymentTopologyPodPortIsTheAllocationUnlessDeclared(t *testing.T) { } for endpoint, want := range map[string]uint32{ "accounts/connect": 8081, "accounts/grpc": 9090, "accounts/rest": 8080, "accounts/authority": 0, - "auth-gateway/grpc": 9090, "auth-gateway/rest": 8080, "telemetry/grpc": 9090, + "auth-gateway/grpc": 9090, "auth-gateway/rest": 8080, "cache/read": 6379, "cache/write": 6379, "store/tcp": 5432, "vault/http": 8200, "frontend/http": 3000, "marketing/http": 3000, } { diff --git a/module/services/accounts/code/pkg/cataloggen/test_targets_test.go b/module/services/accounts/code/pkg/cataloggen/test_targets_test.go index 93bcbfef3..0340ff899 100644 --- a/module/services/accounts/code/pkg/cataloggen/test_targets_test.go +++ b/module/services/accounts/code/pkg/cataloggen/test_targets_test.go @@ -110,7 +110,6 @@ func TestAccountsTargetMetadataRetainsConservativeInputs(t *testing.T) { require.Contains(t, target.Command, "-tags=pure") } else { require.Contains(t, target.RuntimeServices, "store") - require.NotContains(t, target.RuntimeServices, "telemetry") if startsCache[target.Suite] { require.Contains(t, target.RuntimeServices, "cache") } else { diff --git a/module/services/accounts/code/pkg/cataloggen/testdata/network-policy.golden.yaml b/module/services/accounts/code/pkg/cataloggen/testdata/network-policy.golden.yaml index f2c9e20af..da814e95a 100644 --- a/module/services/accounts/code/pkg/cataloggen/testdata/network-policy.golden.yaml +++ b/module/services/accounts/code/pkg/cataloggen/testdata/network-policy.golden.yaml @@ -196,33 +196,6 @@ spec: --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy -metadata: - name: allow-telemetry-from-dependents - namespace: saas-starter -spec: - podSelector: - matchLabels: - app: telemetry - policyTypes: - - Ingress - ingress: - - from: - - podSelector: - matchLabels: - app: accounts - ports: - - protocol: TCP - port: 9090 - - from: - - podSelector: - matchLabels: - app: auth-gateway - ports: - - protocol: TCP - port: 9090 ---- -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy metadata: name: allow-vault-from-dependents namespace: saas-starter @@ -267,13 +240,6 @@ spec: ports: - protocol: TCP port: 5432 - - to: - - podSelector: - matchLabels: - app: telemetry - ports: - - protocol: TCP - port: 9090 - to: - podSelector: matchLabels: @@ -312,13 +278,6 @@ spec: ports: - protocol: TCP port: 6379 - - to: - - podSelector: - matchLabels: - app: telemetry - ports: - - protocol: TCP - port: 9090 --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy @@ -537,55 +496,3 @@ spec: ports: - protocol: TCP port: 443 ---- -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: allow-telemetry-public-egress - namespace: saas-starter -spec: - podSelector: - matchLabels: - app: telemetry - policyTypes: - - Egress - egress: - - to: - - ipBlock: - cidr: 0.0.0.0/0 - except: - - 0.0.0.0/8 - - 10.0.0.0/8 - - 100.64.0.0/10 - - 127.0.0.0/8 - - 169.254.0.0/16 - - 172.16.0.0/12 - - 192.0.0.0/24 - - 192.0.2.0/24 - - 192.31.196.0/24 - - 192.52.193.0/24 - - 192.88.99.0/24 - - 192.168.0.0/16 - - 192.175.48.0/24 - - 198.18.0.0/15 - - 198.51.100.0/24 - - 203.0.113.0/24 - - 224.0.0.0/4 - - 240.0.0.0/4 - - ipBlock: - cidr: ::/0 - except: - - ::/128 - - ::1/128 - - 64:ff9b::/96 - - 64:ff9b:1::/48 - - 100::/64 - - 2001::/23 - - 2001:db8::/32 - - 2002::/16 - - fc00::/7 - - fe80::/10 - - ff00::/8 - ports: - - protocol: TCP - port: 443 diff --git a/module/services/accounts/code/pkg/infra/postgres_webhooks_test.go b/module/services/accounts/code/pkg/infra/postgres_webhooks_test.go index 89e35cf10..723c709e4 100644 --- a/module/services/accounts/code/pkg/infra/postgres_webhooks_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_webhooks_test.go @@ -102,7 +102,7 @@ func runPostgresInfraTests(m *testing.M) int { deps, err := sdk.WithDependencies(ctx, sdk.WithDebug(), sdk.WithSharedControlChannel(), - sdk.WithExcludedDependencies("cache", "vault", "telemetry"), + sdk.WithExcludedDependencies("cache", "vault"), sdk.WithNamingScope("test-infra"), sdk.WithTimeout(90*time.Second), sdk.WithSilence("store"), diff --git a/module/services/accounts/code/pkg/redisintegration/main_test.go b/module/services/accounts/code/pkg/redisintegration/main_test.go index 904e6345d..9784c936e 100644 --- a/module/services/accounts/code/pkg/redisintegration/main_test.go +++ b/module/services/accounts/code/pkg/redisintegration/main_test.go @@ -47,7 +47,7 @@ func runRedisIntegrationTests(m *testing.M) int { deps, err := sdk.WithDependencies(ctx, sdk.WithDebug(), sdk.WithSharedControlChannel(), - sdk.WithExcludedDependencies("vault", "telemetry"), + sdk.WithExcludedDependencies("vault"), sdk.WithNamingScope("redis-integration"), sdk.WithTimeout(120*time.Second), sdk.WithSilence("store", "cache"), diff --git a/module/services/accounts/service.codefly.yaml b/module/services/accounts/service.codefly.yaml index 511b10963..25c211b2f 100644 --- a/module/services/accounts/service.codefly.yaml +++ b/module/services/accounts/service.codefly.yaml @@ -18,9 +18,6 @@ service-dependencies: - name: store endpoints: - name: tcp - - name: telemetry - endpoints: - - name: grpc - name: vault endpoints: - name: http diff --git a/module/services/auth-gateway/code/external/saas-starter/telemetry/saas-starter_telemetry_grpc.pb.go b/module/services/auth-gateway/code/external/saas-starter/telemetry/saas-starter_telemetry_grpc.pb.go deleted file mode 100644 index 689bf2157..000000000 --- a/module/services/auth-gateway/code/external/saas-starter/telemetry/saas-starter_telemetry_grpc.pb.go +++ /dev/null @@ -1,60 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: saas-starter_telemetry_grpc.proto - -package telemetry - -import ( - reflect "reflect" - unsafe "unsafe" - - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -var File_saas_starter_telemetry_grpc_proto protoreflect.FileDescriptor - -const file_saas_starter_telemetry_grpc_proto_rawDesc = "" + - "\n" + - "!saas-starter_telemetry_grpc.protoBhB\x1dSaasStarterTelemetryGrpcProtoP\x01ZEgithub.com/codefly-dev/cli/pkg/builder/clients/saas-starter/telemetry" - -var file_saas_starter_telemetry_grpc_proto_goTypes = []any{} -var file_saas_starter_telemetry_grpc_proto_depIdxs = []int32{ - 0, // [0:0] is the sub-list for method output_type - 0, // [0:0] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name -} - -func init() { file_saas_starter_telemetry_grpc_proto_init() } -func file_saas_starter_telemetry_grpc_proto_init() { - if File_saas_starter_telemetry_grpc_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_starter_telemetry_grpc_proto_rawDesc), len(file_saas_starter_telemetry_grpc_proto_rawDesc)), - NumEnums: 0, - NumMessages: 0, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_saas_starter_telemetry_grpc_proto_goTypes, - DependencyIndexes: file_saas_starter_telemetry_grpc_proto_depIdxs, - }.Build() - File_saas_starter_telemetry_grpc_proto = out.File - file_saas_starter_telemetry_grpc_proto_goTypes = nil - file_saas_starter_telemetry_grpc_proto_depIdxs = nil -} diff --git a/module/services/auth-gateway/service.codefly.yaml b/module/services/auth-gateway/service.codefly.yaml index f7eb093c3..de214971f 100644 --- a/module/services/auth-gateway/service.codefly.yaml +++ b/module/services/auth-gateway/service.codefly.yaml @@ -20,9 +20,6 @@ service-dependencies: - name: cache endpoints: - name: write - - name: telemetry - endpoints: - - name: grpc workspace-configuration-dependencies: # APP_BASE_URL: this host's own public origin. The gateway publishes it in # the RFC 9728 document for a solution's MCP endpoint and requires a token's diff --git a/module/services/frontend/code/src/test/pipeline-setup.ts b/module/services/frontend/code/src/test/pipeline-setup.ts index 08746fad9..85d2f0d0f 100644 --- a/module/services/frontend/code/src/test/pipeline-setup.ts +++ b/module/services/frontend/code/src/test/pipeline-setup.ts @@ -15,8 +15,8 @@ // workers, so starting the graph per file would start one graph per file. // // withDependencies passes --exclude-root: it brings up the frontend's -// dependencies (auth-gateway, Accounts, Postgres, Vault, Redis, storage, -// telemetry) without the Next server the frontend itself would run. The +// dependencies (auth-gateway, Accounts, Postgres, Vault, Redis, storage) +// without the Next server the frontend itself would run. The // pipeline tier addresses the gateway directly, so the root service is dead // weight — and leaving it out means these tests can run alongside a // `codefly run service` dev stack instead of fighting it for the Next dev lock. @@ -47,7 +47,7 @@ export default async function setup(): Promise<(() => Promise) | void> { readyService: "auth-gateway", scope, fixture: "dev-admin", - silents: ["store", "cache", "telemetry"], + silents: ["store", "cache"], echo: process.env.CODEFLY_TEST_ECHO === "1", }); } catch (error) { diff --git a/module/services/telemetry/code/collector/collector.go b/module/services/telemetry/code/collector/collector.go deleted file mode 100644 index ed9196499..000000000 --- a/module/services/telemetry/code/collector/collector.go +++ /dev/null @@ -1,218 +0,0 @@ -package collector - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "net/url" - "strings" - "time" - - collectorlogsv1 "go.opentelemetry.io/proto/otlp/collector/logs/v1" - collectormetricsv1 "go.opentelemetry.io/proto/otlp/collector/metrics/v1" - collectortracev1 "go.opentelemetry.io/proto/otlp/collector/trace/v1" - "google.golang.org/protobuf/proto" -) - -type Config struct { - Exporter string - Endpoint string - Headers string - HTTPClient *http.Client - Logger *slog.Logger -} - -type Collector struct { - exporter string - endpoint string - headers http.Header - client *http.Client - logger *slog.Logger -} - -type TraceService struct { - collectortracev1.UnimplementedTraceServiceServer - collector *Collector -} - -type MetricsService struct { - collectormetricsv1.UnimplementedMetricsServiceServer - collector *Collector -} - -type LogsService struct { - collectorlogsv1.UnimplementedLogsServiceServer - collector *Collector -} - -func (c *Collector) TraceService() *TraceService { return &TraceService{collector: c} } - -func (c *Collector) MetricsService() *MetricsService { - return &MetricsService{collector: c} -} - -func (c *Collector) LogsService() *LogsService { return &LogsService{collector: c} } - -func New(config Config) (*Collector, error) { - exporter := strings.ToLower(strings.TrimSpace(config.Exporter)) - // Fail closed on an empty exporter instead of defaulting to "debug". - // An empty value never meant "the operator chose debug" — every committed - // observability.env sets this key explicitly, and so does - // configurations/local-dogfood/observability.env.example — it meant the configuration never reached the process. - // Defaulting it to debug turned that into SILENT trace loss: the collector - // logged "received OTLP signal" and dropped every span while its own - // ConfigMap said otlphttp. Refusing to start surfaces the same fault in one - // line of pod logs. - if exporter == "" { - return nil, errors.New( - "telemetry: OBSERVABILITY_EXPORTER is required and must be debug or otlphttp; " + - "the observability workspace configuration did not reach this process " + - "(on the local-dogfood profile, copy configurations/local-dogfood/observability.env.example to observability.env)") - } - switch exporter { - case "debug": - if strings.TrimSpace(config.Endpoint) != "" || strings.TrimSpace(config.Headers) != "" { - return nil, errors.New("telemetry: debug exporter cannot have an external endpoint or headers") - } - case "otlphttp": - endpoint, err := url.Parse(strings.TrimRight(strings.TrimSpace(config.Endpoint), "/")) - if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { - return nil, errors.New("telemetry: OTLP/HTTP endpoint must be absolute") - } - // Plaintext is allowed only to loopback, which never leaves the pod and so - // is governed by no network policy. Everything else must be HTTPS, because - // that is the only egress this module actually grants telemetry: - // services/telemetry/service.codefly.yaml declares spec.deployment.public-egress-ports - // [443], which renders allow-telemetry-public-egress — TCP 443 to public IP - // space with 10/8, 172.16/12, 192.168/16 and fc00::/7 excepted — on top of a - // namespace-wide default-deny that has no allow-intra-namespace rule. - // - // A cluster-internal plaintext address (a *.svc name on 4318) is therefore - // denied at the network layer no matter what this check says; accepting it - // here would only move the failure from a startup error to a 10s export - // timeout per batch. Reaching an in-cluster collector is a topology change — - // a declared dependency edge with its regenerated NetworkPolicy — not a - // transport exemption in application code. - local := endpoint.Hostname() == "localhost" || endpoint.Hostname() == "127.0.0.1" - if endpoint.Scheme != "https" && (endpoint.Scheme != "http" || !local) { - return nil, errors.New("telemetry: OTLP/HTTP endpoint must use HTTPS") - } - config.Endpoint = endpoint.String() - default: - return nil, errors.New("telemetry: OBSERVABILITY_EXPORTER must be debug or otlphttp") - } - headers, err := parseHeaders(config.Headers) - if err != nil { - return nil, err - } - client := config.HTTPClient - if client == nil { - client = &http.Client{Timeout: 10 * time.Second} - } - logger := config.Logger - if logger == nil { - logger = slog.Default() - } - return &Collector{ - exporter: exporter, - endpoint: strings.TrimRight(config.Endpoint, "/"), - headers: headers, - client: client, - logger: logger, - }, nil -} - -func (s *TraceService) Export( - ctx context.Context, - request *collectortracev1.ExportTraceServiceRequest, -) (*collectortracev1.ExportTraceServiceResponse, error) { - if err := s.collector.deliver(ctx, "traces", request, len(request.GetResourceSpans())); err != nil { - return nil, err - } - return &collectortracev1.ExportTraceServiceResponse{}, nil -} - -func (s *MetricsService) Export( - ctx context.Context, - request *collectormetricsv1.ExportMetricsServiceRequest, -) (*collectormetricsv1.ExportMetricsServiceResponse, error) { - if err := s.collector.deliver(ctx, "metrics", request, len(request.GetResourceMetrics())); err != nil { - return nil, err - } - return &collectormetricsv1.ExportMetricsServiceResponse{}, nil -} - -func (s *LogsService) Export( - ctx context.Context, - request *collectorlogsv1.ExportLogsServiceRequest, -) (*collectorlogsv1.ExportLogsServiceResponse, error) { - if err := s.collector.deliver(ctx, "logs", request, len(request.GetResourceLogs())); err != nil { - return nil, err - } - return &collectorlogsv1.ExportLogsServiceResponse{}, nil -} - -func (c *Collector) deliver(ctx context.Context, signal string, message proto.Message, resources int) error { - if c.exporter == "debug" { - c.logger.Info("received OTLP signal", "signal", signal, "resources", resources) - return nil - } - body, err := proto.Marshal(message) - if err != nil { - return fmt.Errorf("telemetry: encode %s: %w", signal, err) - } - request, err := http.NewRequestWithContext( - ctx, - http.MethodPost, - c.endpoint+"/v1/"+signal, - bytes.NewReader(body), - ) - if err != nil { - return fmt.Errorf("telemetry: create %s export: %w", signal, err) - } - for name, values := range c.headers { - for _, value := range values { - request.Header.Add(name, value) - } - } - request.Header.Set("Content-Type", "application/x-protobuf") - response, err := c.client.Do(request) - if err != nil { - return fmt.Errorf("telemetry: export %s: %w", signal, err) - } - defer func() { _ = response.Body.Close() }() - _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024)) - if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - return fmt.Errorf("telemetry: export %s returned HTTP %d", signal, response.StatusCode) - } - return nil -} - -func parseHeaders(raw string) (http.Header, error) { - headers := make(http.Header) - raw = strings.TrimSpace(raw) - if raw == "" { - return headers, nil - } - values, err := url.ParseQuery(strings.ReplaceAll(raw, ",", "&")) - if err != nil { - return nil, errors.New("telemetry: OTLP headers must use URL-encoded key=value pairs") - } - for name, entries := range values { - name = strings.TrimSpace(name) - if name == "" { - return nil, errors.New("telemetry: OTLP header name is required") - } - for _, value := range entries { - if strings.ContainsAny(value, "\r\n") { - return nil, errors.New("telemetry: OTLP header value contains a newline") - } - headers.Add(name, value) - } - } - return headers, nil -} diff --git a/module/services/telemetry/code/collector/collector_test.go b/module/services/telemetry/code/collector/collector_test.go deleted file mode 100644 index dc121fbb4..000000000 --- a/module/services/telemetry/code/collector/collector_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package collector_test - -import ( - "context" - "io" - "net" - "net/http" - "net/http/httptest" - "sync" - "testing" - - "telemetry/collector" - - collectortracev1 "go.opentelemetry.io/proto/otlp/collector/trace/v1" - tracev1 "go.opentelemetry.io/proto/otlp/trace/v1" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/test/bufconn" - "google.golang.org/protobuf/proto" - - "github.com/stretchr/testify/require" -) - -func TestCollectorReceivesGRPCAndForwardsOTLPHTTP(t *testing.T) { - var mu sync.Mutex - var forwarded collectortracev1.ExportTraceServiceRequest - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/v1/traces", r.URL.Path) - require.Equal(t, "Bearer test", r.Header.Get("Authorization")) - body, err := io.ReadAll(r.Body) - require.NoError(t, err) - mu.Lock() - defer mu.Unlock() - require.NoError(t, proto.Unmarshal(body, &forwarded)) - w.WriteHeader(http.StatusOK) - })) - defer upstream.Close() - - sink, err := collector.New(collector.Config{ - Exporter: "otlphttp", - Endpoint: upstream.URL, - Headers: "Authorization=Bearer+test", - }) - require.NoError(t, err) - listener := bufconn.Listen(1024 * 1024) - server := grpc.NewServer() - collectortracev1.RegisterTraceServiceServer(server, sink.TraceService()) - go func() { _ = server.Serve(listener) }() - t.Cleanup(server.Stop) - - connection, err := grpc.NewClient( - "passthrough:///bufconn", - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { - return listener.Dial() - }), - ) - require.NoError(t, err) - defer func() { _ = connection.Close() }() - client := collectortracev1.NewTraceServiceClient(connection) - _, err = client.Export(t.Context(), &collectortracev1.ExportTraceServiceRequest{ - ResourceSpans: []*tracev1.ResourceSpans{{}}, - }) - require.NoError(t, err) - mu.Lock() - defer mu.Unlock() - require.Len(t, forwarded.GetResourceSpans(), 1) -} - -func TestCollectorConfigurationFailsClosed(t *testing.T) { - _, err := collector.New(collector.Config{ - Exporter: "debug", Endpoint: "https://collector.example", - }) - require.ErrorContains(t, err, "cannot have") - _, err = collector.New(collector.Config{ - Exporter: "otlphttp", Endpoint: "http://collector.example", - }) - require.ErrorContains(t, err, "HTTPS") -} - -// TestCollectorRequiresExplicitExporter pins the fail-closed behaviour: an empty -// OBSERVABILITY_EXPORTER used to fall back to "debug", which turned a broken -// configuration carrier into silent trace loss instead of a startup failure. -func TestCollectorRequiresExplicitExporter(t *testing.T) { - _, err := collector.New(collector.Config{}) - require.ErrorContains(t, err, "OBSERVABILITY_EXPORTER is required") - - _, err = collector.New(collector.Config{Exporter: " "}) - require.ErrorContains(t, err, "OBSERVABILITY_EXPORTER is required") -} - -// TestCollectorEndpointTransportPolicy pins which OTLP/HTTP destinations may be -// reached in plaintext: loopback only, because loopback never leaves the pod. -// -// The cluster-internal cases are the regression. Exempting *.svc / -// *.svc.cluster.local from the HTTPS requirement looks safe — those names -// resolve only inside the cluster — but this module grants telemetry exactly one -// egress path: public_egress_ports [443] in -// services/telemetry/service.codefly.yaml (spec.deployment), rendered as -// allow-telemetry-public-egress (TCP 443 to public IP space, private ranges -// excepted) over a namespace-wide default-deny with no allow-intra-namespace -// rule. A ClusterIP on 4318 is denied there no matter what this package accepts, -// so accepting it only converts a startup error into a 10s export timeout per -// batch. Reaching an in-cluster collector is a topology change, not a transport -// exemption in application code. -func TestCollectorEndpointTransportPolicy(t *testing.T) { - for _, testCase := range []struct { - name string - endpoint string - accepted bool - }{ - {"loopback name", "http://localhost:4318", true}, - {"loopback address", "http://127.0.0.1:4318", true}, - {"external TLS", "https://example.com", true}, - {"in-cluster collector FQDN is not reachable and not exempt", - "http://otel-collector.otel-collector.svc.cluster.local:4318", false}, - {"in-cluster collector short form", "http://otel-collector.otel-collector.svc:4318", false}, - {"external plaintext", "http://example.com:4318", false}, - {"external plaintext subdomain", "http://otel.internal.ops.example.com:4318", false}, - {"host merely containing svc", "http://svc.example.com:4318", false}, - } { - t.Run(testCase.name, func(t *testing.T) { - _, err := collector.New(collector.Config{ - Exporter: "otlphttp", - Endpoint: testCase.endpoint, - }) - if testCase.accepted { - require.NoError(t, err) - return - } - require.ErrorContains(t, err, "HTTPS") - }) - } -} diff --git a/module/services/telemetry/code/go.mod b/module/services/telemetry/code/go.mod deleted file mode 100644 index 263d1c6b5..000000000 --- a/module/services/telemetry/code/go.mod +++ /dev/null @@ -1,58 +0,0 @@ -module telemetry - -go 1.27.0 - -require ( - github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e - github.com/stretchr/testify v1.12.1 - go.opentelemetry.io/proto/otlp v1.11.0 - google.golang.org/grpc v1.83.2 - google.golang.org/protobuf v1.36.12 -) - -require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect - buf.build/go/protovalidate v1.4.0 // indirect - cel.dev/cel-go v0.32.0 // indirect - cel.dev/expr v0.25.3 // indirect - github.com/Masterminds/semver v1.5.0 // indirect - github.com/Masterminds/semver/v3 v3.5.0 // indirect - github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 // indirect - github.com/go-openapi/analysis v1.0.0 // indirect - github.com/go-openapi/errors v0.22.8 // indirect - github.com/go-openapi/jsonpointer v1.0.1 // indirect - github.com/go-openapi/jsonreference v1.0.2 // indirect - github.com/go-openapi/loads v0.25.3 // indirect - github.com/go-openapi/spec v1.0.1 // indirect - github.com/go-openapi/strfmt v0.27.2 // indirect - github.com/go-openapi/swag/conv v0.29.1 // indirect - github.com/go-openapi/swag/jsonutils v0.29.1 // indirect - github.com/go-openapi/swag/loading v0.29.1 // indirect - github.com/go-openapi/swag/mangling v0.29.1 // indirect - github.com/go-openapi/swag/pools v0.29.1 // indirect - github.com/go-openapi/swag/stringutils v0.29.1 // indirect - github.com/go-openapi/swag/typeutils v0.29.1 // indirect - github.com/go-openapi/swag/yamlutils v0.29.1 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/gofrs/flock v0.13.1 // indirect - github.com/google/go-github/v89 v89.0.0 // indirect - github.com/google/go-querystring v1.2.0 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/oklog/ulid/v2 v2.1.2 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect - go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect - golang.org/x/net v0.59.0 // indirect - golang.org/x/sys v0.48.0 // indirect - golang.org/x/term v0.46.0 // indirect - golang.org/x/text v0.42.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/module/services/telemetry/code/go.sum b/module/services/telemetry/code/go.sum deleted file mode 100644 index a94236ffd..000000000 --- a/module/services/telemetry/code/go.sum +++ /dev/null @@ -1,235 +0,0 @@ -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= -buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM= -buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs= -cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= -cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= -cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= -cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= -github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= -github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= -github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= -github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= -github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= -github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= -github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= -github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= -github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= -github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= -github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= -github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= -github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= -github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 h1:/vzymsu5B9NWwdhWRtlU+68VcVEVDmeZNChRCXAUwlE= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683/go.mod h1:SVF+t040X+9pAXAqBnNE/JkXwmw9peVuKnHKFSOzyzk= -github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= -github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= -github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e h1:V8YSUvAm0QQgHY9V0QmHiHuvf+beHnI10D6cJLWB2eI= -github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e/go.mod h1:X0JAk/gnr6ZcjuPsM+5jvisqY4iyTC1SeVac93iWX8A= -github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= -github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= -github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= -github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= -github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= -github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= -github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= -github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= -github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= -github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= -github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= -github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= -github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-openapi/analysis v1.0.0 h1:sNvbAGCJqUTqIAodr9IVqJMmuZas3YS9ms1dGK9yiJ4= -github.com/go-openapi/analysis v1.0.0/go.mod h1:NhYjJ57fnE+bcE7UwrJyMkhWA3Dfz7TdiBfTVAnos4Y= -github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= -github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= -github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg= -github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY= -github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc= -github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs= -github.com/go-openapi/loads v0.25.3 h1:V+jKy/thXWdLJUuYC8sZX2dICyAa8M3DokF70jj34P0= -github.com/go-openapi/loads v0.25.3/go.mod h1:LgLyCSOLBL2Qnj0Ps1oo2YH8jZoEKsMeVZCc+ALIxFo= -github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk= -github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8= -github.com/go-openapi/strfmt v0.27.2 h1:SG32SlbwNy92s0KJiVxt2joJeFdqIYHvwrA0OU6HqzQ= -github.com/go-openapi/strfmt v0.27.2/go.mod h1:M4CKsMO0Fb8qR10+1Ra75wCKNNquy+Vj+4LWZrhTo2E= -github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw= -github.com/go-openapi/swag v0.28.0/go.mod h1:4qYnT3Cqr1p1VknOdPo70evN4rgQnAg6jwApHyxSGIg= -github.com/go-openapi/swag/cmdutils v0.28.0 h1:7TOeNtkYru1SG8Y34tDh9WBbLsMqGnptuxWiHREPZ4Q= -github.com/go-openapi/swag/cmdutils v0.28.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= -github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA= -github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM= -github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU= -github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= -github.com/go-openapi/swag/jsonutils v0.29.1 h1:AFCxs0eQZ24/QyfhVHM2t49rMz7Vv3XCsZQI6yrNy+c= -github.com/go-openapi/swag/jsonutils v0.29.1/go.mod h1:u3+sCfJpttDpcmS5kpm0yxL6GK0eWgODsx8Yw8fcqNM= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1 h1:BiiXE31Bx9SfpsMmOQj5KYpUhTZBpLVriVhJDuLuY2o= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1/go.mod h1:julgTUKZ9/D0j6O7GKajmRs+812FWxQg/mMpGunWSjg= -github.com/go-openapi/swag/loading v0.29.1 h1:FCv5fG8UhTdDJa2R7w+5O9Ekpcbw7tt0nFWvmDKGBjc= -github.com/go-openapi/swag/loading v0.29.1/go.mod h1:N0ESuem4p2oedKal8EJhciqnJ9Q9Wmt83L1CRB3Fouw= -github.com/go-openapi/swag/mangling v0.29.1 h1:lHALtvYCdxVnRl4GrHmFPwfBTZYIObqdGNSKyu/8D6I= -github.com/go-openapi/swag/mangling v0.29.1/go.mod h1:SAop9pB7PUjQ/CGCNf/JmCKTRK+GDO+RqE9UHqC/N6s= -github.com/go-openapi/swag/netutils v0.28.0 h1:YXN6TALEi2pzts8/8GNm6T61HTAZsieukGZidap989k= -github.com/go-openapi/swag/netutils v0.28.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= -github.com/go-openapi/swag/pools v0.29.1 h1:NRogYxdEW9SjRM4mkAOji9iefO4MRXq3p/ZJcoQbUKg= -github.com/go-openapi/swag/pools v0.29.1/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc= -github.com/go-openapi/swag/stringutils v0.29.1 h1:1ykunK7iJQk1uOO7+oUH1ukbsK85fFCOiCFMOVSY+F0= -github.com/go-openapi/swag/stringutils v0.29.1/go.mod h1:7fSqZ+z8Qc0tOfAAK0jVa5qFGrnIlRi6n7NeGGrr1vc= -github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M= -github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE= -github.com/go-openapi/swag/yamlutils v0.29.1 h1:69w3tsBajm7MR/fejLy7HD/3J68Ys1SeeZMEzZ3w2sk= -github.com/go-openapi/swag/yamlutils v0.29.1/go.mod h1:rgsp3vT/QdWzKwn43CigDwjOGIenPyTZMKnxEM8jZOA= -github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs= -github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs= -github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8= -github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= -github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= -github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v89 v89.0.0 h1:35bEK5XoEcF3PZrlVbl9XN63f5BcJRA/UGkxeC9xPg0= -github.com/google/go-github/v89 v89.0.0/go.mod h1:QLcbU0ipeAqQuR5KSg8c2lql4Qk1EwJ2dWz/0rP4Nho= -github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= -github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= -github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= -github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= -github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= -github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= -github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= -github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= -github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= -github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= -github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= -github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= -github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= -github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= -github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= -github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= -github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4= -github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= -github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= -github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= -github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= -github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= -github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= -github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= -github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= -github.com/yoheimuta/go-protoparser/v4 v4.14.2 h1:/P/LlX1CF9NaTWEltGcIZVvNlPbhABuAnBtAWpb3+74= -github.com/yoheimuta/go-protoparser/v4 v4.14.2/go.mod h1:AHNNnSWnb0UoL4QgHPiOAg2BniQceFscPI5X/BZNHl8= -github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= -github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 h1:B2h3uqicet1CT2N5TOFhS+Gq++9i0/CLmaxvhmhtP5s= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0/go.mod h1:dylvB+ZiiwMvsDij9O84Uy7SijLgHMX4mbkncds+4Sw= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= -go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= -go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= -go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= -go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= -go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= -go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= -go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= -go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= -go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= -go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk= -golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= -golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= -golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= -golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs= -google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= -google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= -google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= -google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= -google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= -gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg= -k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas= -sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= -sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= -sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= -sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= -sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= -sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= diff --git a/module/services/telemetry/code/main.go b/module/services/telemetry/code/main.go deleted file mode 100644 index f4d0ee527..000000000 --- a/module/services/telemetry/code/main.go +++ /dev/null @@ -1,128 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "net" - "os" - "os/signal" - "strconv" - "syscall" - - "telemetry/collector" - - codefly "github.com/codefly-dev/sdk-go" - collectorlogsv1 "go.opentelemetry.io/proto/otlp/collector/logs/v1" - collectormetricsv1 "go.opentelemetry.io/proto/otlp/collector/metrics/v1" - collectortracev1 "go.opentelemetry.io/proto/otlp/collector/trace/v1" - "google.golang.org/grpc" -) - -// observabilityConfiguration is the workspace configuration group that carries -// the collector's upstream-exporter settings. Values arrive from either half of -// the group — configurations//observability.env or its .secret.env -// sibling, which is where OTEL_EXPORTER_OTLP_HEADERS lives — because -// WorkspaceValue consults the public namespace first and then the secret one. -const observabilityConfiguration = "observability" - -// exporterKeys are the three settings that make up one exporter configuration. -// They are resolved together, never individually; see resolveExporterConfig. -var exporterKeys = struct{ exporter, endpoint, headers string }{ - exporter: "OBSERVABILITY_EXPORTER", - endpoint: "OTEL_EXPORTER_OTLP_ENDPOINT", - headers: "OTEL_EXPORTER_OTLP_HEADERS", -} - -// workspaceExporterConfig reads all three settings from the observability -// workspace configuration group, reporting whether the group delivered any of -// them. -// -// Reading the group is load-bearing, not cosmetic. telemetry declares -// `observability` as a workspace-configuration-dependency, and a declared group -// reaches a deployed pod ONLY under prefixed names — -// CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__, via envFrom a -// ConfigMap — while codefly.Init builds an in-process map without ever mutating -// the process environment. A bare os.Getenv("OBSERVABILITY_EXPORTER") therefore -// resolved to "" wherever the configuration arrives that way; collector.New -// defaulted that empty value to "debug", and the collector logged and dropped -// every span it received while its own ConfigMap said otlphttp. -func workspaceExporterConfig(ctx context.Context) (collector.Config, bool) { - value := func(key string) string { - resolved, err := codefly.For(ctx).WorkspaceValue(observabilityConfiguration, key) - if err != nil { - return "" - } - return resolved - } - config := collector.Config{ - Exporter: value(exporterKeys.exporter), - Endpoint: value(exporterKeys.endpoint), - Headers: value(exporterKeys.headers), - } - return config, config.Exporter != "" || config.Endpoint != "" || config.Headers != "" -} - -// resolveExporterConfig picks ONE authority for the whole exporter -// configuration: the workspace group if it delivered anything at all, otherwise -// the bare process environment, so a local or non-Codefly run still works. -// -// The choice is deliberately made for the tuple, not per key. collector.New -// cross-validates these three values (debug rejects an endpoint; otlphttp -// requires one), so mixing sources produces configurations no operator wrote. -// Resolving key by key meant a developer who exported OBSERVABILITY_EXPORTER -// and OTEL_EXPORTER_OTLP_ENDPOINT in their shell could no -// longer start the collector: the committed local group supplied -// OBSERVABILITY_EXPORTER=debug while its empty endpoint fell through to the -// shell's, and debug+endpoint is refused. The same split let an ambient -// OTEL_EXPORTER_OTLP_HEADERS — the credential, and a name the OpenTelemetry -// spec makes ubiquitous — attach itself to a workspace-configured endpoint it -// was never issued for, whenever the observability.secret.env half was not -// provisioned. One authority for all three makes both states unrepresentable. -func resolveExporterConfig(ctx context.Context) collector.Config { - if config, delivered := workspaceExporterConfig(ctx); delivered { - return config - } - return collector.Config{ - Exporter: os.Getenv(exporterKeys.exporter), - Endpoint: os.Getenv(exporterKeys.endpoint), - Headers: os.Getenv(exporterKeys.headers), - } -} - -func main() { - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - provider, err := codefly.Init(ctx) - if err != nil { - log.Fatal(err) - } - ctx = provider.Inject(ctx) - defer codefly.CatchPanic(ctx) - - port := codefly.For(ctx).WithDefaultNetwork().API("grpc").NetworkInstance().Port - if port == 0 { - log.Fatal("telemetry: Codefly did not inject the collector gRPC port") - } - sink, err := collector.New(resolveExporterConfig(ctx)) - if err != nil { - log.Fatal(err) - } - listener, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(int(port)))) - if err != nil { - log.Fatal(err) - } - server := grpc.NewServer() - collectortracev1.RegisterTraceServiceServer(server, sink.TraceService()) - collectormetricsv1.RegisterMetricsServiceServer(server, sink.MetricsService()) - collectorlogsv1.RegisterLogsServiceServer(server, sink.LogsService()) - go func() { - if err := server.Serve(listener); err != nil { - log.Printf("telemetry: serve: %v", err) - stop() - } - }() - fmt.Printf("OpenTelemetry collector listening on Codefly gRPC endpoint %d\n", port) - <-ctx.Done() - server.GracefulStop() -} diff --git a/module/services/telemetry/code/main_test.go b/module/services/telemetry/code/main_test.go deleted file mode 100644 index d7002508f..000000000 --- a/module/services/telemetry/code/main_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package main - -import ( - "context" - "testing" - - "telemetry/collector" - - codefly "github.com/codefly-dev/sdk-go" - "github.com/stretchr/testify/require" -) - -// clearWorkspaceObservability removes every carrier the observability group can -// arrive under, so a test that means "no workspace configuration" actually gets -// it. Without this a test passes only because a neighbouring test happened to -// restore these first, and fails wherever they are genuinely exported — which is -// any process started by `codefly run`. -func clearWorkspaceObservability(t *testing.T) { - t.Helper() - for _, prefix := range []string{ - "CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__", - "CODEFLY__WORKSPACE_SECRET_CONFIGURATION__OBSERVABILITY__", - } { - for _, key := range []string{ - "OBSERVABILITY_EXPORTER", - "OTEL_EXPORTER_OTLP_ENDPOINT", - "OTEL_EXPORTER_OTLP_HEADERS", - } { - t.Setenv(prefix+key, "") - } - } -} - -// TestResolveExporterConfigPrefersWorkspaceValue is the regression test for the -// boundary that broke in deployment: workspace configuration reaches the pod -// under prefixed names only, so reading the bare name returned "" and the -// collector silently downgraded itself to the debug exporter. -func TestResolveExporterConfigPrefersWorkspaceValue(t *testing.T) { - clearWorkspaceObservability(t) - t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__OBSERVABILITY_EXPORTER", "otlphttp") - t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__OTEL_EXPORTER_OTLP_ENDPOINT", - "https://collector.example") - t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__OBSERVABILITY__OTEL_EXPORTER_OTLP_HEADERS", - "Authorization=Bearer+workspace") - // The bare names are what main.go used to read; they must lose to the - // prefixed workspace values above — including the header, which is the value - // that carries the credential. - t.Setenv("OBSERVABILITY_EXPORTER", "debug") - t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://wrong-backend.example") - t.Setenv("OTEL_EXPORTER_OTLP_HEADERS", "Authorization=Bearer+ambient") - require.NoError(t, codefly.LoadEnvironmentVariables()) - - config := resolveExporterConfig(context.Background()) - require.Equal(t, "otlphttp", config.Exporter) - require.Equal(t, "https://collector.example", config.Endpoint) - require.Equal(t, "Authorization=Bearer+workspace", config.Headers) -} - -// TestResolveExporterConfigTakesWholeTupleFromOneAuthority is the regression -// test for per-key resolution. The workspace group selects debug and says -// nothing about an endpoint or headers; the process environment carries both. -// Resolving key by key produced debug+endpoint — a combination collector.New -// refuses — so a developer who had exported these in their shell could no -// longer start the collector at all. It also -// stopped an ambient OTEL_EXPORTER_OTLP_HEADERS credential from being attached -// to an endpoint it was never issued for. -func TestResolveExporterConfigTakesWholeTupleFromOneAuthority(t *testing.T) { - clearWorkspaceObservability(t) - t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__OBSERVABILITY_EXPORTER", "debug") - t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://shell-backend.example") - t.Setenv("OTEL_EXPORTER_OTLP_HEADERS", "Authorization=Bearer+ambient") - require.NoError(t, codefly.LoadEnvironmentVariables()) - - config := resolveExporterConfig(context.Background()) - require.Equal(t, "debug", config.Exporter) - require.Empty(t, config.Endpoint, "endpoint must not be mixed in from the process environment") - require.Empty(t, config.Headers, "an ambient credential must not reach a workspace-selected exporter") - - // The consequence the mixing produced: the collector refused to start. - _, err := collector.New(config) - require.NoError(t, err) -} - -// TestResolveExporterConfigFallsBackToProcessEnvironment keeps a local or -// non-Codefly run working: with no workspace configuration injected, the bare -// process variables are still honoured — as a complete tuple. -func TestResolveExporterConfigFallsBackToProcessEnvironment(t *testing.T) { - clearWorkspaceObservability(t) - t.Setenv("OBSERVABILITY_EXPORTER", "otlphttp") - t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://collector.example") - t.Setenv("OTEL_EXPORTER_OTLP_HEADERS", "Authorization=Bearer+ambient") - require.NoError(t, codefly.LoadEnvironmentVariables()) - - config := resolveExporterConfig(context.Background()) - require.Equal(t, "otlphttp", config.Exporter) - require.Equal(t, "https://collector.example", config.Endpoint) - require.Equal(t, "Authorization=Bearer+ambient", config.Headers) -} diff --git a/module/services/telemetry/code/pkg/gen/saas/telemetry/v1/schema_marker.pb.go b/module/services/telemetry/code/pkg/gen/saas/telemetry/v1/schema_marker.pb.go deleted file mode 100644 index 6821690f7..000000000 --- a/module/services/telemetry/code/pkg/gen/saas/telemetry/v1/schema_marker.pb.go +++ /dev/null @@ -1,117 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: saas/telemetry/v1/schema_marker.proto - -package telemetryv1 - -import ( - reflect "reflect" - sync "sync" - unsafe "unsafe" - - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// SchemaMarker keeps the Codefly protobuf sync input valid without duplicating -// the standard OpenTelemetry Protocol service definitions. -type SchemaMarker struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SchemaMarker) Reset() { - *x = SchemaMarker{} - mi := &file_saas_telemetry_v1_schema_marker_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SchemaMarker) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SchemaMarker) ProtoMessage() {} - -func (x *SchemaMarker) ProtoReflect() protoreflect.Message { - mi := &file_saas_telemetry_v1_schema_marker_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SchemaMarker.ProtoReflect.Descriptor instead. -func (*SchemaMarker) Descriptor() ([]byte, []int) { - return file_saas_telemetry_v1_schema_marker_proto_rawDescGZIP(), []int{0} -} - -var File_saas_telemetry_v1_schema_marker_proto protoreflect.FileDescriptor - -const file_saas_telemetry_v1_schema_marker_proto_rawDesc = "" + - "\n" + - "%saas/telemetry/v1/schema_marker.proto\x12\x11saas.telemetry.v1\"\x0e\n" + - "\fSchemaMarkerB\xc1\x01\n" + - "\x15com.saas.telemetry.v1B\x11SchemaMarkerProtoP\x01Z/telemetry/pkg/gen/saas/telemetry/v1;telemetryv1\xa2\x02\x03STX\xaa\x02\x11Saas.Telemetry.V1\xca\x02\x11Saas\\Telemetry\\V1\xe2\x02\x1dSaas\\Telemetry\\V1\\GPBMetadata\xea\x02\x13Saas::Telemetry::V1b\x06proto3" - -var ( - file_saas_telemetry_v1_schema_marker_proto_rawDescOnce sync.Once - file_saas_telemetry_v1_schema_marker_proto_rawDescData []byte -) - -func file_saas_telemetry_v1_schema_marker_proto_rawDescGZIP() []byte { - file_saas_telemetry_v1_schema_marker_proto_rawDescOnce.Do(func() { - file_saas_telemetry_v1_schema_marker_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_saas_telemetry_v1_schema_marker_proto_rawDesc), len(file_saas_telemetry_v1_schema_marker_proto_rawDesc))) - }) - return file_saas_telemetry_v1_schema_marker_proto_rawDescData -} - -var file_saas_telemetry_v1_schema_marker_proto_msgTypes = make([]protoimpl.MessageInfo, 1) -var file_saas_telemetry_v1_schema_marker_proto_goTypes = []any{ - (*SchemaMarker)(nil), // 0: saas.telemetry.v1.SchemaMarker -} -var file_saas_telemetry_v1_schema_marker_proto_depIdxs = []int32{ - 0, // [0:0] is the sub-list for method output_type - 0, // [0:0] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name -} - -func init() { file_saas_telemetry_v1_schema_marker_proto_init() } -func file_saas_telemetry_v1_schema_marker_proto_init() { - if File_saas_telemetry_v1_schema_marker_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_telemetry_v1_schema_marker_proto_rawDesc), len(file_saas_telemetry_v1_schema_marker_proto_rawDesc)), - NumEnums: 0, - NumMessages: 1, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_saas_telemetry_v1_schema_marker_proto_goTypes, - DependencyIndexes: file_saas_telemetry_v1_schema_marker_proto_depIdxs, - MessageInfos: file_saas_telemetry_v1_schema_marker_proto_msgTypes, - }.Build() - File_saas_telemetry_v1_schema_marker_proto = out.File - file_saas_telemetry_v1_schema_marker_proto_goTypes = nil - file_saas_telemetry_v1_schema_marker_proto_depIdxs = nil -} diff --git a/module/services/telemetry/proto/README.md b/module/services/telemetry/proto/README.md deleted file mode 100644 index e70d7c6dc..000000000 --- a/module/services/telemetry/proto/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Telemetry protobuf input - -The telemetry gateway implements the standard OpenTelemetry Protocol services -from `go.opentelemetry.io/proto/otlp`; it does not duplicate those upstream -service definitions. - -The local `SchemaMarker` is deliberately code-generation-free. It keeps the -Codefly Go gRPC agent's required protobuf sync input valid for a service that -depends exclusively on upstream wire schemas. diff --git a/module/services/telemetry/proto/buf.gen.yaml b/module/services/telemetry/proto/buf.gen.yaml deleted file mode 100644 index 63afd5b44..000000000 --- a/module/services/telemetry/proto/buf.gen.yaml +++ /dev/null @@ -1,11 +0,0 @@ -version: v2 -clean: true -managed: - enabled: true - override: - - file_option: go_package_prefix - value: telemetry/pkg/gen -plugins: - - local: protoc-gen-go - out: ../code/pkg/gen - opt: paths=source_relative diff --git a/module/services/telemetry/proto/buf.yaml b/module/services/telemetry/proto/buf.yaml deleted file mode 100644 index cb3b14b9f..000000000 --- a/module/services/telemetry/proto/buf.yaml +++ /dev/null @@ -1,6 +0,0 @@ -version: v2 -modules: - - path: . -lint: - use: - - STANDARD diff --git a/module/services/telemetry/proto/saas/telemetry/v1/schema_marker.proto b/module/services/telemetry/proto/saas/telemetry/v1/schema_marker.proto deleted file mode 100644 index ad48f2919..000000000 --- a/module/services/telemetry/proto/saas/telemetry/v1/schema_marker.proto +++ /dev/null @@ -1,7 +0,0 @@ -syntax = "proto3"; - -package saas.telemetry.v1; - -// SchemaMarker keeps the Codefly protobuf sync input valid without duplicating -// the standard OpenTelemetry Protocol service definitions. -message SchemaMarker {} diff --git a/module/services/telemetry/service.codefly.yaml b/module/services/telemetry/service.codefly.yaml deleted file mode 100644 index b4e307607..000000000 --- a/module/services/telemetry/service.codefly.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: telemetry -version: 0.0.0 -description: | - In-graph OTLP gateway. Accounts resolves this endpoint exclusively - through the Codefly SDK; the collector either logs locally or forwards - trace, metric, and log protobufs to a configured OTLP/HTTP destination. -agent: - kind: codefly:service - name: go-grpc - version: 0.1.50 - publisher: codefly.dev -workspace-configuration-dependencies: - - observability -endpoints: - - name: grpc -spec: - hot-reload: true - protocol-output-dirs: - - code/pkg/gen - deployment: - # No endpoint-ports: go-grpc binds every endpoint on the port Codefly - # allocates for it, so each pod port is its deployed port, which the - # render takes from Codefly. endpoint-ports is only for a port the - # process binds at a fixed number the allocation does not give it. - public-egress-ports: - - 443 diff --git a/module/tools/composition/composition_test.go b/module/tools/composition/composition_test.go index cf7e16a9f..7f99bf294 100644 --- a/module/tools/composition/composition_test.go +++ b/module/tools/composition/composition_test.go @@ -329,11 +329,18 @@ permissions: func writeCoreInput(t *testing.T, fixture *coreCompositionFixture) { t.Helper() + // The input names the package release it was made for, which the generator + // holds to the package it runs inside: the version is the manifest's, not a + // literal that every release would have to chase. + manifest, err := corecomposition.LoadPackageManifest(findModuleRoot(t)) + if err != nil { + t.Fatalf("load package manifest: %v", err) + } input := corecomposition.CompositionInput{ Schema: "codefly/composition-input/v2", Module: fixture.descriptor.Name, Package: "codefly/saas-starter", - Version: "0.1.0", + Version: manifest.Version, ConsumerRoot: fixture.consumerRoot, Projection: fixture.projection, Contracts: map[string]string{ diff --git a/module/tools/modulepackage/modulepackage_test.go b/module/tools/modulepackage/modulepackage_test.go index 0a08bfe86..da79a25c8 100644 --- a/module/tools/modulepackage/modulepackage_test.go +++ b/module/tools/modulepackage/modulepackage_test.go @@ -23,11 +23,11 @@ func TestStarterManifestIsTheCoreV2Contract(t *testing.T) { if err != nil { t.Fatal(err) } - if manifest.ID != "codefly/saas-starter" || manifest.Version != "0.1.0" { + if manifest.ID != "codefly/saas-starter" || manifest.Version != "0.2.0" { t.Fatalf("unexpected package identity: %s@%s", manifest.ID, manifest.Version) } - if len(manifest.Services) != 8 { - t.Fatalf("provided service count = %d, want 8", len(manifest.Services)) + if len(manifest.Services) != 7 { + t.Fatalf("provided service count = %d, want 7", len(manifest.Services)) } for _, contract := range []string{"composition", "frontendPlugin", "settings", "permissions", "fixtures"} { if manifest.Contracts[contract] == "" { From 152fd504896be70a61226e2bc0dda68ceca2499d Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:55:03 -0400 Subject: [PATCH 03/20] fix(accounts,auth-gateway): the observability state decides; build the metrics exporter from the endpoint URL (Refs #1022) The `observability` group is layered: a cell's values override the module's local defaults one key at a time. A deployed producer therefore receives `TELEMETRY_STATE=available` and `OTEL_EXPORTER_OTLP_ENDPOINT` from the cell and a leftover `TELEMETRY_ABSENT_REASON` from the local profile, and the previous resolution refused that as "both set", so the service would not start on the one configuration a cell is certain to deliver. The state now decides, in both mirrored copies of telemetry_destination.go: - `available` requires a non-empty endpoint. A leftover reason is ignored. - `absent` requires a non-empty reason. A leftover endpoint is ignored, and is not parsed, so a stale one cannot stop a cell that says it has no collector. - A missing or unknown state, and the key the state requires being missing, still refuse to start. What was ignored is carried on the destination and reported once, where the process acts on the answer (accounts' doWork, auth-gateway's main), not by requireStartupConfiguration, which reads the group a second time only to refuse. The notice names the key and never its value: a leftover endpoint may carry credentials and a leftover reason is free text. The metrics exporter is now built with `otlpmetricgrpc.WithEndpointURL`, so the official exporter reads its transport off the URL's scheme instead of being given host:port and a separately derived insecure flag. The destination carries the origin as scheme://host:port with the port always explicit, because gRPC would otherwise dial 443 whatever the scheme. Traces keep wool's Enable path and its labelled https stopgap; a separate core change teaches wool TLS-from-scheme. An empty URL is refused rather than handed to WithEndpointURL, which would fall back to the exporter's own localhost:4317 and report success. The exporter used to be created before the resource, and a resource that could not be built returned without shutting it down, leaking its client connection. The resource is now built first, so there is no exporter to leak; the later failure paths already shut the provider down, which shuts the exporter down with it. Tests: both layered cases, a module default of `absent` with a reason under a cell override of `available` with an endpoint (and the reverse), a leftover endpoint that is malformed or carries credentials, blank leftovers, the same through the SDK's lookup, and the requirement that a state still supplies its own key. An https:// URL is shown to be TLS by failing to export to a plaintext receiver, and the test fails if the exporter is given a constant insecure transport. The mirror test still holds the two copies identical. FEATURES, the local profile and the local-dogfood example describe the rule. Co-Authored-By: Claude Opus 5.5 --- .../local-dogfood/observability.env.example | 3 +- module/FEATURES.md | 18 +- module/configurations/local/observability.env | 4 + .../accounts/code/telemetry_destination.go | 97 ++++++--- .../code/telemetry_destination_test.go | 187 +++++++++++++++--- .../accounts/code/telemetry_metrics.go | 19 +- .../accounts/code/telemetry_metrics_test.go | 61 +++++- module/services/accounts/code/work.go | 5 + module/services/auth-gateway/code/main.go | 4 + .../code/telemetry_destination.go | 97 ++++++--- .../code/telemetry_destination_test.go | 187 +++++++++++++++--- .../auth-gateway/code/telemetry_metrics.go | 19 +- .../code/telemetry_metrics_test.go | 61 +++++- 13 files changed, 636 insertions(+), 126 deletions(-) diff --git a/configurations/local-dogfood/observability.env.example b/configurations/local-dogfood/observability.env.example index 73ad6b219..84f833283 100644 --- a/configurations/local-dogfood/observability.env.example +++ b/configurations/local-dogfood/observability.env.example @@ -15,4 +15,5 @@ TELEMETRY_ABSENT_REASON=A local dogfood run has no cell collector; traces go to # # The endpoint is the collector's OTLP/gRPC address. http:// is plaintext, which # only belongs on loopback or inside the mesh; https:// is TLS. Set the endpoint -# when the state is available and the reason when it is absent, never both. +# when the state is available and the reason when it is absent. The state decides: the +# other key is ignored, and the process logs that once. diff --git a/module/FEATURES.md b/module/FEATURES.md index 29566a9fc..15c4b824e 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -656,8 +656,8 @@ Environment variables consumed by the api: | `ERROR_TRACKING_MODE` | Explicit `disabled` or `sentry`; rejects partial config | | `SENTRY_DSN` | Server Sentry DSN, required in Sentry mode | | `TELEMETRY_STATE` | `observability` group, delivered by the platform: `available` or `absent`, never empty; accounts and auth-gateway refuse to start without it | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | `observability` group: the cell collector's OTLP/gRPC address, present only when `available`; `http://` is plaintext on the wire because the mesh supplies mTLS, `https://` is TLS | -| `TELEMETRY_ABSENT_REASON` | `observability` group: why the cell has no collector, present only when `absent`; logged once at startup | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | `observability` group: the cell collector's OTLP/gRPC address, required when `available` and ignored when `absent`; `http://` is plaintext on the wire because the mesh supplies mTLS, `https://` is TLS | +| `TELEMETRY_ABSENT_REASON` | `observability` group: why the cell has no collector, required when `absent` and ignored when `available`; logged once at startup | | `ABUSE_PROTECTION_MODE` | Explicit `disabled` or `turnstile` | | `TURNSTILE_SECRET_KEY` | Server-only Siteverify credential | | `TURNSTILE_ALLOWED_HOSTNAMES` | Exact accepted Turnstile response hostnames | @@ -668,11 +668,15 @@ export traces and unsampled request and Go runtime metrics over OTLP/gRPC to the cell's collector at `OTEL_EXPORTER_OTLP_ENDPOINT`. The auth-gateway covers both its HTTP gateway and gRPC ext_authz authorization service. When it is `absent`, they export nothing over OTLP, log `TELEMETRY_ABSENT_REASON` once and boot; a local run -then uses wool's stdout tracer for traces. Anything else — the state missing or -unknown, `available` without an endpoint, `absent` without a reason, or both -set — refuses to start, because a group that did not arrive is not a cell without -a collector. An `https://` endpoint is refused at startup for now: wool's OTLP -tracer dials plaintext only, so it would be sent in the clear and reported as TLS. +then uses wool's stdout tracer for traces. The state decides: a cell's values +override the module's local defaults one key at a time, so a deployed cell that +says `available` with an endpoint can still carry the `absent` reason the local +profile left behind. The key the state does not use is ignored, and a startup +warning names it once. Anything else — the state missing or unknown, `available` +without an endpoint, or `absent` without a reason — refuses to start, because a +group that did not arrive is not a cell without a collector. An `https://` +endpoint is refused at startup for now: wool's OTLP tracer dials plaintext only, +so it would be sent in the clear and reported as TLS. Metrics leave by OTLP push alone — decided 2026-10-06, because the cell's collector is the record for traces and metrics take the same path — so neither service serves a scrape endpoint. `service.name` comes from `OTEL_SERVICE_NAME` or diff --git a/module/configurations/local/observability.env b/module/configurations/local/observability.env index ed8a6da2c..4d7ddcb97 100644 --- a/module/configurations/local/observability.env +++ b/module/configurations/local/observability.env @@ -10,6 +10,10 @@ # TELEMETRY_STATE=absent the cell has none. Also sets TELEMETRY_ABSENT_REASON to # say why. # +# The state decides. A cell's values override these defaults one key at a time, so a +# cell that sets `available` and an endpoint still carries the reason below; the +# process ignores the key its state does not use and logs that once. +# # A local run has no cell, so it has no collector: traces go to wool's stdout tracer # and metrics are not exported. Logs always go to stdout. TELEMETRY_STATE=absent diff --git a/module/services/accounts/code/telemetry_destination.go b/module/services/accounts/code/telemetry_destination.go index e3296805d..c752d82fd 100644 --- a/module/services/accounts/code/telemetry_destination.go +++ b/module/services/accounts/code/telemetry_destination.go @@ -32,10 +32,17 @@ import ( // collector: the cell does, and a workload that cannot find it does not // substitute one. // +// The state decides. The group is layered: the cell's values override the +// module's local defaults one key at a time, so a deployed workload sees the +// cell's `available` and endpoint beside the `absent` reason the module's local +// profile left behind. The key that does not belong to the state is therefore +// ignored, not refused, and reported once through IgnoredNotice. What each state +// requires is unchanged: `available` needs an endpoint, `absent` needs a reason. +// // A group that did not arrive is not a cell without a backend. Reading the first // as the second would leave a deployment that converges green and exports -// nothing, so anything that is not exactly one of the two answers above refuses -// to start, naming what is missing. +// nothing, so a missing or unknown state, or the key the state requires being +// absent, refuses to start, naming what is missing. // // This file exists byte for byte in services/accounts/code and // services/auth-gateway/code: the two services are independent Go modules with @@ -56,17 +63,28 @@ const ( maxAbsentReasonLength = 300 ) -// telemetryDestination is the resolved answer. Exactly one of Endpoint and +// telemetryDestination is the resolved answer. Exactly one of URL and // AbsentReason is set. type telemetryDestination struct { - // Endpoint is the collector's OTLP/gRPC address as host:port, the form the - // exporters take. Empty when the cell has no collector. + // URL is the collector's origin as scheme://host:port, the port always + // explicit, which is the form otlpmetricgrpc.WithEndpointURL takes: the + // exporter reads its transport off the scheme. Empty when the cell has no + // collector. + URL string + // Endpoint is the same address as host:port. wool's tracer takes it in this + // form and has no option that reads a URL. Empty when the cell has no + // collector. Endpoint string // Insecure is true when the endpoint was http://, which the mesh protects. - // It is derived from the endpoint's scheme and never assumed. + // wool's tracer needs it spelled out; it is derived from the endpoint's + // scheme and never assumed. Insecure bool - // AbsentReason says why the cell has no collector. Empty when Endpoint is set. + // AbsentReason says why the cell has no collector. Empty when URL is set. AbsentReason string + // Ignored names the key the group carried that has no meaning for the state + // that decided: the absent reason under `available`, the endpoint under + // `absent`. Empty when the group carried nothing extra. + Ignored string } // Available reports whether the cell has a collector to export to. @@ -74,6 +92,23 @@ func (d telemetryDestination) Available() bool { return d.Endpoint != "" } +// IgnoredNotice is the one line to log, once, when the group carried a key the +// state does not use. It is empty otherwise. It names the key and never its +// value: a leftover endpoint may carry credentials, and a leftover reason is +// free text. +func (d telemetryDestination) IgnoredNotice() string { + if d.Ignored == "" { + return "" + } + state := telemetryStateAbsent + if d.Available() { + state = telemetryStateAvailable + } + return fmt.Sprintf( + "observability: %s is set but %s is `%s`, which decides; %s is ignored (a leftover from a lower layer of the group)", + d.Ignored, telemetryStateKey, state, d.Ignored) +} + // observabilityValue reads one key of the `observability` group through the // SDK. It deliberately has no fallback to a bare process variable: an ambient // OTEL_EXPORTER_OTLP_ENDPOINT in a shell or a pod is conventionally the @@ -112,13 +147,11 @@ func resolveTelemetryDestination(read func(key string) string) (telemetryDestina "observability: %s is %q in the `observability` configuration group; it must be `%s` or `%s`", telemetryStateKey, state, telemetryStateAvailable, telemetryStateAbsent) } - if endpoint != "" && reason != "" { - return telemetryDestination{}, fmt.Errorf( - "observability: the `observability` configuration group sets both %s and %s; "+ - "a cell either has a collector or says why it has none", - telemetryEndpointKey, telemetryAbsentReasonKey) - } + // The state decides: the key that belongs to the other state is a leftover + // of a lower layer and is neither read nor refused. The endpoint under + // `absent` is not even parsed, so a stale one cannot stop a cell that says + // it has no collector. if state == telemetryStateAbsent { if reason == "" { return telemetryDestination{}, fmt.Errorf( @@ -126,7 +159,11 @@ func resolveTelemetryDestination(read func(key string) string) (telemetryDestina "an absent collector must say why", telemetryStateKey, telemetryStateAbsent, telemetryAbsentReasonKey) } - return telemetryDestination{AbsentReason: reason}, nil + destination := telemetryDestination{AbsentReason: reason} + if endpoint != "" { + destination.Ignored = telemetryEndpointKey + } + return destination, nil } if endpoint == "" { @@ -134,39 +171,46 @@ func resolveTelemetryDestination(read func(key string) string) (telemetryDestina "observability: %s is `%s` but %s is not set in the `observability` configuration group", telemetryStateKey, telemetryStateAvailable, telemetryEndpointKey) } - hostPort, insecure, err := parseCollectorEndpoint(endpoint) + origin, hostPort, insecure, err := parseCollectorEndpoint(endpoint) if err != nil { return telemetryDestination{}, fmt.Errorf("observability: %s: %w", telemetryEndpointKey, err) } - return telemetryDestination{Endpoint: hostPort, Insecure: insecure}, nil + destination := telemetryDestination{URL: origin, Endpoint: hostPort, Insecure: insecure} + if reason != "" { + destination.Ignored = telemetryAbsentReasonKey + } + return destination, nil } -// parseCollectorEndpoint turns the delivered URL into the host:port the OTLP -// gRPC exporters take, and reads the transport off its scheme. The URL is never -// echoed back: a malformed one may carry credentials. -func parseCollectorEndpoint(raw string) (hostPort string, insecure bool, err error) { +// parseCollectorEndpoint turns the delivered URL into the two forms the +// exporters take — the origin with an explicit port, and host:port — and reads +// the transport off its scheme. The URL is never echoed back: a malformed one +// may carry credentials. +func parseCollectorEndpoint(raw string) (origin, hostPort string, insecure bool, err error) { parsed, parseErr := url.Parse(raw) if parseErr != nil { - return "", false, errors.New("is not a URL; expected http://host:port or https://host:port") + return "", "", false, errors.New("is not a URL; expected http://host:port or https://host:port") } switch parsed.Scheme { case "http": insecure = true case "https": default: - return "", false, fmt.Errorf("must start with http:// or https://, got scheme %q", parsed.Scheme) + return "", "", false, fmt.Errorf("must start with http:// or https://, got scheme %q", parsed.Scheme) } if parsed.Hostname() == "" { - return "", false, errors.New("has no host") + return "", "", false, errors.New("has no host") } if parsed.User != nil { - return "", false, errors.New("must not carry credentials") + return "", "", false, errors.New("must not carry credentials") } // OTLP/gRPC addresses a collector, not a path: a path or a query is the // OTLP/HTTP form (/v1/traces), which this exporter does not speak. if (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { - return "", false, errors.New("must be the collector's origin only, with no path, query or fragment") + return "", "", false, errors.New("must be the collector's origin only, with no path, query or fragment") } + // The port is made explicit because gRPC would otherwise dial 443 whatever + // the scheme, and a plaintext http:// collector with no port is not on 443. port := parsed.Port() if port == "" { port = "80" @@ -174,7 +218,8 @@ func parseCollectorEndpoint(raw string) (hostPort string, insecure bool, err err port = "443" } } - return net.JoinHostPort(parsed.Hostname(), port), insecure, nil + hostPort = net.JoinHostPort(parsed.Hostname(), port) + return parsed.Scheme + "://" + hostPort, hostPort, insecure, nil } // boundedLine collapses a free-text value to one bounded line. diff --git a/module/services/accounts/code/telemetry_destination_test.go b/module/services/accounts/code/telemetry_destination_test.go index c2d1e4d24..4ac8a2b75 100644 --- a/module/services/accounts/code/telemetry_destination_test.go +++ b/module/services/accounts/code/telemetry_destination_test.go @@ -29,32 +29,39 @@ func TestTelemetryDestinationAvailableExportsToTheEndpoint(t *testing.T) { })) require.NoError(t, err) require.True(t, destination.Available()) + require.Equal(t, collector, destination.URL, "the metrics exporter reads its transport off this URL") require.Equal(t, "otel-collector.otel-collector.svc.cluster.local:4317", destination.Endpoint) require.True(t, destination.Insecure, "http:// is plaintext on the wire; the mesh supplies mTLS") require.Empty(t, destination.AbsentReason) + require.Empty(t, destination.Ignored) + require.Empty(t, destination.IgnoredNotice()) } -// The transport comes from the endpoint's scheme, never from a constant. +// The transport comes from the endpoint's scheme, never from a constant, and +// the URL handed to the metrics exporter always carries an explicit port: gRPC +// would otherwise dial 443 whatever the scheme. func TestTelemetryDestinationReadsTransportFromTheScheme(t *testing.T) { for endpoint, want := range map[string]struct { + url string hostPort string insecure bool }{ - "http://collector.example:4317": {"collector.example:4317", true}, - "https://collector.example:4317": {"collector.example:4317", false}, - "https://collector.example": {"collector.example:443", false}, - "http://collector.example": {"collector.example:80", true}, - "http://collector.example:4317/": {"collector.example:4317", true}, - " http://collector.example:4317 ": {"collector.example:4317", true}, - "http://[2001:db8::1]:4317": {"[2001:db8::1]:4317", true}, - "https://10.0.0.7:4317": {"10.0.0.7:4317", false}, - "http://otel-collector.ns.svc:4317/": {"otel-collector.ns.svc:4317", true}, + "http://collector.example:4317": {"http://collector.example:4317", "collector.example:4317", true}, + "https://collector.example:4317": {"https://collector.example:4317", "collector.example:4317", false}, + "https://collector.example": {"https://collector.example:443", "collector.example:443", false}, + "http://collector.example": {"http://collector.example:80", "collector.example:80", true}, + "http://collector.example:4317/": {"http://collector.example:4317", "collector.example:4317", true}, + " http://collector.example:4317 ": {"http://collector.example:4317", "collector.example:4317", true}, + "http://[2001:db8::1]:4317": {"http://[2001:db8::1]:4317", "[2001:db8::1]:4317", true}, + "https://10.0.0.7:4317": {"https://10.0.0.7:4317", "10.0.0.7:4317", false}, + "http://otel-collector.ns.svc:4317/": {"http://otel-collector.ns.svc:4317", "otel-collector.ns.svc:4317", true}, } { destination, err := resolveTelemetryDestination(group(map[string]string{ "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": endpoint, })) require.NoError(t, err, endpoint) + require.Equal(t, want.url, destination.URL, endpoint) require.Equal(t, want.hostPort, destination.Endpoint, endpoint) require.Equal(t, want.insecure, destination.Insecure, endpoint) } @@ -69,8 +76,11 @@ func TestTelemetryDestinationAbsentCarriesTheReason(t *testing.T) { })) require.NoError(t, err) require.False(t, destination.Available()) + require.Empty(t, destination.URL) require.Empty(t, destination.Endpoint) require.Equal(t, "This cell's bucket has no ops cell, so no collector is rendered.", destination.AbsentReason) + require.Empty(t, destination.Ignored) + require.Empty(t, destination.IgnoredNotice()) } // The reason is operator-written free text that reaches a log line. @@ -86,7 +96,9 @@ func TestTelemetryDestinationAbsentReasonIsOneBoundedLine(t *testing.T) { } // The third outcome: anything else refuses to start, naming what is wrong. -// Configuration that did not arrive is never read as "no collector". +// Configuration that did not arrive is never read as "no collector". A key the +// state does not use is not a refusal — it is ignored, below — but a key the +// state requires, missing, always is. func TestTelemetryDestinationRefusesEverythingElse(t *testing.T) { for name, tc := range map[string]struct { values map[string]string @@ -136,20 +148,6 @@ func TestTelemetryDestinationRefusesEverythingElse(t *testing.T) { values: map[string]string{"TELEMETRY_STATE": "absent", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, want: "TELEMETRY_ABSENT_REASON is not set", }, - "available with both an endpoint and a reason": { - values: map[string]string{ - "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, - "TELEMETRY_ABSENT_REASON": "no collector", - }, - want: "sets both OTEL_EXPORTER_OTLP_ENDPOINT and TELEMETRY_ABSENT_REASON", - }, - "absent with both an endpoint and a reason": { - values: map[string]string{ - "TELEMETRY_STATE": "absent", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, - "TELEMETRY_ABSENT_REASON": "no collector", - }, - want: "sets both OTEL_EXPORTER_OTLP_ENDPOINT and TELEMETRY_ABSENT_REASON", - }, "endpoint without a scheme": { values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "otel-collector.ns.svc:4317"}, want: "must start with http:// or https://", @@ -181,6 +179,129 @@ func TestTelemetryDestinationRefusesEverythingElse(t *testing.T) { } } +// The state decides. The cell's values override the module's local defaults one +// key at a time, so a deployed producer sees `available` and an endpoint from the +// cell beside the reason the local profile left behind. That is not a +// contradiction to refuse: the reason is ignored, and said so once. +func TestTelemetryDestinationAvailableIgnoresALeftoverReason(t *testing.T) { + destination, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "available", + "OTEL_EXPORTER_OTLP_ENDPOINT": collector, + "TELEMETRY_ABSENT_REASON": "A local run has no cell collector.", + })) + require.NoError(t, err) + require.True(t, destination.Available()) + require.Equal(t, collector, destination.URL) + require.Empty(t, destination.AbsentReason, "a leftover reason must not turn an available collector into an absent one") + require.Equal(t, "TELEMETRY_ABSENT_REASON", destination.Ignored) + notice := destination.IgnoredNotice() + require.Contains(t, notice, "TELEMETRY_ABSENT_REASON is set but TELEMETRY_STATE is `available`") + require.NotContains(t, notice, "A local run", "the notice names the key, never its free-text value") +} + +// ... and the other way round: a cell that says it has no collector is not +// refused for an endpoint a lower layer left behind, and that endpoint is not +// even parsed. +func TestTelemetryDestinationAbsentIgnoresALeftoverEndpoint(t *testing.T) { + for name, leftover := range map[string]string{ + "a usable endpoint": collector, + "a malformed endpoint": "otel-collector.ns.svc:4317", + "an endpoint with a user": "http://user:hunter2@collector.example:4317", + "an unreadable endpoint": "http://[::1", + } { + t.Run(name, func(t *testing.T) { + destination, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "absent", + "TELEMETRY_ABSENT_REASON": "This cell has no ops cell.", + "OTEL_EXPORTER_OTLP_ENDPOINT": leftover, + })) + require.NoError(t, err) + require.False(t, destination.Available(), "a leftover endpoint must not turn an absent collector into an available one") + require.Empty(t, destination.URL) + require.Empty(t, destination.Endpoint) + require.Equal(t, "This cell has no ops cell.", destination.AbsentReason) + require.Equal(t, "OTEL_EXPORTER_OTLP_ENDPOINT", destination.Ignored) + notice := destination.IgnoredNotice() + require.Contains(t, notice, "OTEL_EXPORTER_OTLP_ENDPOINT is set but TELEMETRY_STATE is `absent`") + require.NotContains(t, notice, "hunter2", "the notice names the key, never its value") + }) + } +} + +// A blank leftover is nothing left over. +func TestTelemetryDestinationBlankLeftoversAreNotNoticed(t *testing.T) { + available, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, "TELEMETRY_ABSENT_REASON": " \n ", + })) + require.NoError(t, err) + require.Empty(t, available.IgnoredNotice()) + + absent, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "no collector", "OTEL_EXPORTER_OTLP_ENDPOINT": " ", + })) + require.NoError(t, err) + require.Empty(t, absent.IgnoredNotice()) +} + +// layered is what the platform hands a workload: each layer overrides the one +// below it key by key, so a key a higher layer does not set survives from the +// lower one. +func layered(layers ...map[string]string) func(string) string { + merged := map[string]string{} + for _, layer := range layers { + for key, value := range layer { + merged[key] = value + } + } + return group(merged) +} + +// The case this exists for: the module's local profile defaults to `absent` with +// a reason, and a deployed cell overrides the state and the endpoint but not the +// reason. The cell's answer wins, whichever way it points. +func TestTelemetryDestinationTheCellOverridesTheModuleDefaults(t *testing.T) { + moduleDefault := map[string]string{ + "TELEMETRY_STATE": "absent", + "TELEMETRY_ABSENT_REASON": "A local run has no cell collector.", + } + + t.Run("a cell with a collector overrides the local absent default", func(t *testing.T) { + destination, err := resolveTelemetryDestination(layered(moduleDefault, map[string]string{ + "TELEMETRY_STATE": "available", + "OTEL_EXPORTER_OTLP_ENDPOINT": collector, + })) + require.NoError(t, err) + require.True(t, destination.Available()) + require.Equal(t, collector, destination.URL) + require.Empty(t, destination.AbsentReason) + require.Equal(t, "TELEMETRY_ABSENT_REASON", destination.Ignored) + }) + + t.Run("a cell with no collector, over a default that had an endpoint, is absent", func(t *testing.T) { + destination, err := resolveTelemetryDestination(layered( + map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, + map[string]string{"TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "This cell has no ops cell."}, + )) + require.NoError(t, err) + require.False(t, destination.Available()) + require.Equal(t, "This cell has no ops cell.", destination.AbsentReason) + require.Equal(t, "OTEL_EXPORTER_OTLP_ENDPOINT", destination.Ignored) + }) + + t.Run("a cell that overrides only the state still has to supply what it requires", func(t *testing.T) { + _, err := resolveTelemetryDestination(layered(moduleDefault, map[string]string{"TELEMETRY_STATE": "available"})) + require.ErrorContains(t, err, "OTEL_EXPORTER_OTLP_ENDPOINT is not set", + "the module default's reason is not an endpoint") + + _, err = resolveTelemetryDestination(layered( + map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, + map[string]string{"TELEMETRY_STATE": "absent"}, + )) + require.ErrorContains(t, err, "TELEMETRY_ABSENT_REASON is not set", + "the module default's endpoint is not a reason") + }) +} + // A refusal quotes no part of an endpoint, which may carry credentials. func TestTelemetryDestinationRefusalDoesNotEchoCredentials(t *testing.T) { _, err := resolveTelemetryDestination(group(map[string]string{ @@ -228,6 +349,22 @@ func TestConfiguredTelemetryDestinationReadsTheObservabilityGroup(t *testing.T) require.Equal(t, "a local run has no cell collector", destination.AbsentReason) } +// Through the SDK's lookup, with the keys exactly as a deployed cell delivers +// them after the layers merge: the cell's state and endpoint, and the reason the +// module's local profile left behind. +func TestConfiguredTelemetryDestinationStateDecidesAcrossLayers(t *testing.T) { + const prefix = "CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__" + t.Setenv(prefix+"TELEMETRY_STATE", "available") + t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", collector) + t.Setenv(prefix+"TELEMETRY_ABSENT_REASON", "A local run has no cell collector.") + + destination, err := configuredTelemetryDestination() + require.NoError(t, err) + require.True(t, destination.Available()) + require.Equal(t, collector, destination.URL) + require.NotEmpty(t, destination.IgnoredNotice()) +} + // Configuration that cannot work is refused before the process acquires // anything, including in a local run: a group that did not arrive is not a cell // without a collector anywhere. diff --git a/module/services/accounts/code/telemetry_metrics.go b/module/services/accounts/code/telemetry_metrics.go index 9ab41d0ea..b7ce54122 100644 --- a/module/services/accounts/code/telemetry_metrics.go +++ b/module/services/accounts/code/telemetry_metrics.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" runtimemetrics "runtime/metrics" "time" @@ -20,17 +21,21 @@ type otelMetrics struct { } func enableOTELMetrics(ctx context.Context, destination telemetryDestination) (*otelMetrics, error) { - options := []otlpmetricgrpc.Option{otlpmetricgrpc.WithEndpoint(destination.Endpoint)} - if destination.Insecure { - // http://: plaintext on the wire, because the mesh supplies mTLS. An - // https:// endpoint takes the exporter's default, TLS. - options = append(options, otlpmetricgrpc.WithInsecure()) + if destination.URL == "" { + // WithEndpointURL("") would fall back to the exporter's own default, + // localhost:4317: a process that exports to nowhere and reports success. + return nil, errors.New("telemetry: no collector URL to export metrics to") } - exporter, err := otlpmetricgrpc.New(ctx, options...) + // The resource comes before the exporter, so a failure here leaves nothing + // to shut down: an exporter holds a client connection that only Shutdown + // releases. + res, err := currentTelemetryResource(ctx) if err != nil { return nil, err } - res, err := currentTelemetryResource(ctx) + // The exporter reads the transport off the URL's scheme itself: http:// is + // plaintext on the wire, because the mesh supplies mTLS, and https:// is TLS. + exporter, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithEndpointURL(destination.URL)) if err != nil { return nil, err } diff --git a/module/services/accounts/code/telemetry_metrics_test.go b/module/services/accounts/code/telemetry_metrics_test.go index fd7fd94af..7af83c684 100644 --- a/module/services/accounts/code/telemetry_metrics_test.go +++ b/module/services/accounts/code/telemetry_metrics_test.go @@ -71,7 +71,7 @@ func TestTelemetryMetricsExportRuntimeAndUnsampledRED(t *testing.T) { ) otel.SetTracerProvider(tracerProvider) - metrics, err := enableOTELMetrics(t.Context(), telemetryDestination{Endpoint: endpoint, Insecure: true}) + metrics, err := enableOTELMetrics(t.Context(), availableAt(t, "http", endpoint)) require.NoError(t, err) require.NotNil(t, metrics) t.Cleanup(func() { @@ -111,6 +111,65 @@ func TestTelemetryMetricsExportRuntimeAndUnsampledRED(t *testing.T) { require.Empty(t, spanExporter.GetSpans()) } +// availableAt resolves the destination the way the process does, from the +// endpoint the platform would deliver, so these tests hand the exporter the URL +// it really receives rather than a hand-built struct. +func availableAt(t *testing.T, scheme, hostPort string) telemetryDestination { + t.Helper() + destination, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "available", + "OTEL_EXPORTER_OTLP_ENDPOINT": scheme + "://" + hostPort, + })) + require.NoError(t, err) + return destination +} + +// The exporter reads its transport off the URL's scheme, so an https:// URL is +// TLS and cannot talk to a plaintext receiver. A constant WithInsecure() would +// have let it. +func TestEnableOTELMetricsDerivesTransportFromTheURLScheme(t *testing.T) { + endpoint, capture := startMetricCapture(t) + previousMeterProvider := otel.GetMeterProvider() + metrics, err := enableOTELMetrics(t.Context(), availableAt(t, "https", endpoint)) + require.NoError(t, err) + require.NotNil(t, metrics) + t.Cleanup(func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = metrics.Shutdown(shutdownCtx) // the flush it attempts is the failure under test + otel.SetMeterProvider(previousMeterProvider) + }) + + flushCtx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + require.Error(t, metrics.provider.ForceFlush(flushCtx), "a TLS client cannot export to a plaintext receiver") + select { + case <-capture.requests: + t.Fatal("an https:// destination reached a plaintext receiver") + default: + } +} + +// A destination with no collector URL is refused. WithEndpointURL("") would +// otherwise fall back to the exporter's own default, localhost:4317. +func TestEnableOTELMetricsRefusesADestinationWithoutAURL(t *testing.T) { + metrics, err := enableOTELMetrics(t.Context(), telemetryDestination{AbsentReason: "no collector"}) + require.ErrorContains(t, err, "no collector URL") + require.Nil(t, metrics) +} + +// A resource that cannot be built fails the whole call, and the call builds it +// before it creates the exporter: there is no exporter, and so no client +// connection, left running behind the failure. +func TestEnableOTELMetricsFailsWhenTheResourceCannotBeBuilt(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "an-attribute-with-no-value") + endpoint, _ := startMetricCapture(t) + metrics, err := enableOTELMetrics(t.Context(), availableAt(t, "http", endpoint)) + require.Error(t, err) + require.Nil(t, metrics) +} + func startMetricCapture(t *testing.T) (string, *metricCapture) { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index b27571e53..34faee487 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -112,6 +112,11 @@ func doWork(ctx context.Context) (Clean, error) { w.Info("OTEL export disabled: the cell has no collector", wool.Field("reason", telemetryDestination.AbsentReason)) } + // The state decided; a key it does not use is a leftover, reported here once. + // requireStartupConfiguration reads the group too, but only to refuse. + if notice := telemetryDestination.IgnoredNotice(); notice != "" { + w.Warn(notice) + } store, err := infra.NewPostgresStore(ctx) if err != nil { diff --git a/module/services/auth-gateway/code/main.go b/module/services/auth-gateway/code/main.go index 33a03c8bb..4ea768fe6 100644 --- a/module/services/auth-gateway/code/main.go +++ b/module/services/auth-gateway/code/main.go @@ -66,6 +66,10 @@ func main() { } else { log.Printf("OTEL export disabled: the cell has no collector: %s", telemetryDestination.AbsentReason) } + // The state decided; a key it does not use is a leftover, reported here once. + if notice := telemetryDestination.IgnoredNotice(); notice != "" { + log.Print(notice) + } defer func() { shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/module/services/auth-gateway/code/telemetry_destination.go b/module/services/auth-gateway/code/telemetry_destination.go index e3296805d..c752d82fd 100644 --- a/module/services/auth-gateway/code/telemetry_destination.go +++ b/module/services/auth-gateway/code/telemetry_destination.go @@ -32,10 +32,17 @@ import ( // collector: the cell does, and a workload that cannot find it does not // substitute one. // +// The state decides. The group is layered: the cell's values override the +// module's local defaults one key at a time, so a deployed workload sees the +// cell's `available` and endpoint beside the `absent` reason the module's local +// profile left behind. The key that does not belong to the state is therefore +// ignored, not refused, and reported once through IgnoredNotice. What each state +// requires is unchanged: `available` needs an endpoint, `absent` needs a reason. +// // A group that did not arrive is not a cell without a backend. Reading the first // as the second would leave a deployment that converges green and exports -// nothing, so anything that is not exactly one of the two answers above refuses -// to start, naming what is missing. +// nothing, so a missing or unknown state, or the key the state requires being +// absent, refuses to start, naming what is missing. // // This file exists byte for byte in services/accounts/code and // services/auth-gateway/code: the two services are independent Go modules with @@ -56,17 +63,28 @@ const ( maxAbsentReasonLength = 300 ) -// telemetryDestination is the resolved answer. Exactly one of Endpoint and +// telemetryDestination is the resolved answer. Exactly one of URL and // AbsentReason is set. type telemetryDestination struct { - // Endpoint is the collector's OTLP/gRPC address as host:port, the form the - // exporters take. Empty when the cell has no collector. + // URL is the collector's origin as scheme://host:port, the port always + // explicit, which is the form otlpmetricgrpc.WithEndpointURL takes: the + // exporter reads its transport off the scheme. Empty when the cell has no + // collector. + URL string + // Endpoint is the same address as host:port. wool's tracer takes it in this + // form and has no option that reads a URL. Empty when the cell has no + // collector. Endpoint string // Insecure is true when the endpoint was http://, which the mesh protects. - // It is derived from the endpoint's scheme and never assumed. + // wool's tracer needs it spelled out; it is derived from the endpoint's + // scheme and never assumed. Insecure bool - // AbsentReason says why the cell has no collector. Empty when Endpoint is set. + // AbsentReason says why the cell has no collector. Empty when URL is set. AbsentReason string + // Ignored names the key the group carried that has no meaning for the state + // that decided: the absent reason under `available`, the endpoint under + // `absent`. Empty when the group carried nothing extra. + Ignored string } // Available reports whether the cell has a collector to export to. @@ -74,6 +92,23 @@ func (d telemetryDestination) Available() bool { return d.Endpoint != "" } +// IgnoredNotice is the one line to log, once, when the group carried a key the +// state does not use. It is empty otherwise. It names the key and never its +// value: a leftover endpoint may carry credentials, and a leftover reason is +// free text. +func (d telemetryDestination) IgnoredNotice() string { + if d.Ignored == "" { + return "" + } + state := telemetryStateAbsent + if d.Available() { + state = telemetryStateAvailable + } + return fmt.Sprintf( + "observability: %s is set but %s is `%s`, which decides; %s is ignored (a leftover from a lower layer of the group)", + d.Ignored, telemetryStateKey, state, d.Ignored) +} + // observabilityValue reads one key of the `observability` group through the // SDK. It deliberately has no fallback to a bare process variable: an ambient // OTEL_EXPORTER_OTLP_ENDPOINT in a shell or a pod is conventionally the @@ -112,13 +147,11 @@ func resolveTelemetryDestination(read func(key string) string) (telemetryDestina "observability: %s is %q in the `observability` configuration group; it must be `%s` or `%s`", telemetryStateKey, state, telemetryStateAvailable, telemetryStateAbsent) } - if endpoint != "" && reason != "" { - return telemetryDestination{}, fmt.Errorf( - "observability: the `observability` configuration group sets both %s and %s; "+ - "a cell either has a collector or says why it has none", - telemetryEndpointKey, telemetryAbsentReasonKey) - } + // The state decides: the key that belongs to the other state is a leftover + // of a lower layer and is neither read nor refused. The endpoint under + // `absent` is not even parsed, so a stale one cannot stop a cell that says + // it has no collector. if state == telemetryStateAbsent { if reason == "" { return telemetryDestination{}, fmt.Errorf( @@ -126,7 +159,11 @@ func resolveTelemetryDestination(read func(key string) string) (telemetryDestina "an absent collector must say why", telemetryStateKey, telemetryStateAbsent, telemetryAbsentReasonKey) } - return telemetryDestination{AbsentReason: reason}, nil + destination := telemetryDestination{AbsentReason: reason} + if endpoint != "" { + destination.Ignored = telemetryEndpointKey + } + return destination, nil } if endpoint == "" { @@ -134,39 +171,46 @@ func resolveTelemetryDestination(read func(key string) string) (telemetryDestina "observability: %s is `%s` but %s is not set in the `observability` configuration group", telemetryStateKey, telemetryStateAvailable, telemetryEndpointKey) } - hostPort, insecure, err := parseCollectorEndpoint(endpoint) + origin, hostPort, insecure, err := parseCollectorEndpoint(endpoint) if err != nil { return telemetryDestination{}, fmt.Errorf("observability: %s: %w", telemetryEndpointKey, err) } - return telemetryDestination{Endpoint: hostPort, Insecure: insecure}, nil + destination := telemetryDestination{URL: origin, Endpoint: hostPort, Insecure: insecure} + if reason != "" { + destination.Ignored = telemetryAbsentReasonKey + } + return destination, nil } -// parseCollectorEndpoint turns the delivered URL into the host:port the OTLP -// gRPC exporters take, and reads the transport off its scheme. The URL is never -// echoed back: a malformed one may carry credentials. -func parseCollectorEndpoint(raw string) (hostPort string, insecure bool, err error) { +// parseCollectorEndpoint turns the delivered URL into the two forms the +// exporters take — the origin with an explicit port, and host:port — and reads +// the transport off its scheme. The URL is never echoed back: a malformed one +// may carry credentials. +func parseCollectorEndpoint(raw string) (origin, hostPort string, insecure bool, err error) { parsed, parseErr := url.Parse(raw) if parseErr != nil { - return "", false, errors.New("is not a URL; expected http://host:port or https://host:port") + return "", "", false, errors.New("is not a URL; expected http://host:port or https://host:port") } switch parsed.Scheme { case "http": insecure = true case "https": default: - return "", false, fmt.Errorf("must start with http:// or https://, got scheme %q", parsed.Scheme) + return "", "", false, fmt.Errorf("must start with http:// or https://, got scheme %q", parsed.Scheme) } if parsed.Hostname() == "" { - return "", false, errors.New("has no host") + return "", "", false, errors.New("has no host") } if parsed.User != nil { - return "", false, errors.New("must not carry credentials") + return "", "", false, errors.New("must not carry credentials") } // OTLP/gRPC addresses a collector, not a path: a path or a query is the // OTLP/HTTP form (/v1/traces), which this exporter does not speak. if (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { - return "", false, errors.New("must be the collector's origin only, with no path, query or fragment") + return "", "", false, errors.New("must be the collector's origin only, with no path, query or fragment") } + // The port is made explicit because gRPC would otherwise dial 443 whatever + // the scheme, and a plaintext http:// collector with no port is not on 443. port := parsed.Port() if port == "" { port = "80" @@ -174,7 +218,8 @@ func parseCollectorEndpoint(raw string) (hostPort string, insecure bool, err err port = "443" } } - return net.JoinHostPort(parsed.Hostname(), port), insecure, nil + hostPort = net.JoinHostPort(parsed.Hostname(), port) + return parsed.Scheme + "://" + hostPort, hostPort, insecure, nil } // boundedLine collapses a free-text value to one bounded line. diff --git a/module/services/auth-gateway/code/telemetry_destination_test.go b/module/services/auth-gateway/code/telemetry_destination_test.go index 4d81a7698..fe4f32e4a 100644 --- a/module/services/auth-gateway/code/telemetry_destination_test.go +++ b/module/services/auth-gateway/code/telemetry_destination_test.go @@ -29,32 +29,39 @@ func TestTelemetryDestinationAvailableExportsToTheEndpoint(t *testing.T) { })) require.NoError(t, err) require.True(t, destination.Available()) + require.Equal(t, collector, destination.URL, "the metrics exporter reads its transport off this URL") require.Equal(t, "otel-collector.otel-collector.svc.cluster.local:4317", destination.Endpoint) require.True(t, destination.Insecure, "http:// is plaintext on the wire; the mesh supplies mTLS") require.Empty(t, destination.AbsentReason) + require.Empty(t, destination.Ignored) + require.Empty(t, destination.IgnoredNotice()) } -// The transport comes from the endpoint's scheme, never from a constant. +// The transport comes from the endpoint's scheme, never from a constant, and +// the URL handed to the metrics exporter always carries an explicit port: gRPC +// would otherwise dial 443 whatever the scheme. func TestTelemetryDestinationReadsTransportFromTheScheme(t *testing.T) { for endpoint, want := range map[string]struct { + url string hostPort string insecure bool }{ - "http://collector.example:4317": {"collector.example:4317", true}, - "https://collector.example:4317": {"collector.example:4317", false}, - "https://collector.example": {"collector.example:443", false}, - "http://collector.example": {"collector.example:80", true}, - "http://collector.example:4317/": {"collector.example:4317", true}, - " http://collector.example:4317 ": {"collector.example:4317", true}, - "http://[2001:db8::1]:4317": {"[2001:db8::1]:4317", true}, - "https://10.0.0.7:4317": {"10.0.0.7:4317", false}, - "http://otel-collector.ns.svc:4317/": {"otel-collector.ns.svc:4317", true}, + "http://collector.example:4317": {"http://collector.example:4317", "collector.example:4317", true}, + "https://collector.example:4317": {"https://collector.example:4317", "collector.example:4317", false}, + "https://collector.example": {"https://collector.example:443", "collector.example:443", false}, + "http://collector.example": {"http://collector.example:80", "collector.example:80", true}, + "http://collector.example:4317/": {"http://collector.example:4317", "collector.example:4317", true}, + " http://collector.example:4317 ": {"http://collector.example:4317", "collector.example:4317", true}, + "http://[2001:db8::1]:4317": {"http://[2001:db8::1]:4317", "[2001:db8::1]:4317", true}, + "https://10.0.0.7:4317": {"https://10.0.0.7:4317", "10.0.0.7:4317", false}, + "http://otel-collector.ns.svc:4317/": {"http://otel-collector.ns.svc:4317", "otel-collector.ns.svc:4317", true}, } { destination, err := resolveTelemetryDestination(group(map[string]string{ "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": endpoint, })) require.NoError(t, err, endpoint) + require.Equal(t, want.url, destination.URL, endpoint) require.Equal(t, want.hostPort, destination.Endpoint, endpoint) require.Equal(t, want.insecure, destination.Insecure, endpoint) } @@ -69,8 +76,11 @@ func TestTelemetryDestinationAbsentCarriesTheReason(t *testing.T) { })) require.NoError(t, err) require.False(t, destination.Available()) + require.Empty(t, destination.URL) require.Empty(t, destination.Endpoint) require.Equal(t, "This cell's bucket has no ops cell, so no collector is rendered.", destination.AbsentReason) + require.Empty(t, destination.Ignored) + require.Empty(t, destination.IgnoredNotice()) } // The reason is operator-written free text that reaches a log line. @@ -86,7 +96,9 @@ func TestTelemetryDestinationAbsentReasonIsOneBoundedLine(t *testing.T) { } // The third outcome: anything else refuses to start, naming what is wrong. -// Configuration that did not arrive is never read as "no collector". +// Configuration that did not arrive is never read as "no collector". A key the +// state does not use is not a refusal — it is ignored, below — but a key the +// state requires, missing, always is. func TestTelemetryDestinationRefusesEverythingElse(t *testing.T) { for name, tc := range map[string]struct { values map[string]string @@ -136,20 +148,6 @@ func TestTelemetryDestinationRefusesEverythingElse(t *testing.T) { values: map[string]string{"TELEMETRY_STATE": "absent", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, want: "TELEMETRY_ABSENT_REASON is not set", }, - "available with both an endpoint and a reason": { - values: map[string]string{ - "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, - "TELEMETRY_ABSENT_REASON": "no collector", - }, - want: "sets both OTEL_EXPORTER_OTLP_ENDPOINT and TELEMETRY_ABSENT_REASON", - }, - "absent with both an endpoint and a reason": { - values: map[string]string{ - "TELEMETRY_STATE": "absent", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, - "TELEMETRY_ABSENT_REASON": "no collector", - }, - want: "sets both OTEL_EXPORTER_OTLP_ENDPOINT and TELEMETRY_ABSENT_REASON", - }, "endpoint without a scheme": { values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "otel-collector.ns.svc:4317"}, want: "must start with http:// or https://", @@ -181,6 +179,129 @@ func TestTelemetryDestinationRefusesEverythingElse(t *testing.T) { } } +// The state decides. The cell's values override the module's local defaults one +// key at a time, so a deployed producer sees `available` and an endpoint from the +// cell beside the reason the local profile left behind. That is not a +// contradiction to refuse: the reason is ignored, and said so once. +func TestTelemetryDestinationAvailableIgnoresALeftoverReason(t *testing.T) { + destination, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "available", + "OTEL_EXPORTER_OTLP_ENDPOINT": collector, + "TELEMETRY_ABSENT_REASON": "A local run has no cell collector.", + })) + require.NoError(t, err) + require.True(t, destination.Available()) + require.Equal(t, collector, destination.URL) + require.Empty(t, destination.AbsentReason, "a leftover reason must not turn an available collector into an absent one") + require.Equal(t, "TELEMETRY_ABSENT_REASON", destination.Ignored) + notice := destination.IgnoredNotice() + require.Contains(t, notice, "TELEMETRY_ABSENT_REASON is set but TELEMETRY_STATE is `available`") + require.NotContains(t, notice, "A local run", "the notice names the key, never its free-text value") +} + +// ... and the other way round: a cell that says it has no collector is not +// refused for an endpoint a lower layer left behind, and that endpoint is not +// even parsed. +func TestTelemetryDestinationAbsentIgnoresALeftoverEndpoint(t *testing.T) { + for name, leftover := range map[string]string{ + "a usable endpoint": collector, + "a malformed endpoint": "otel-collector.ns.svc:4317", + "an endpoint with a user": "http://user:hunter2@collector.example:4317", + "an unreadable endpoint": "http://[::1", + } { + t.Run(name, func(t *testing.T) { + destination, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "absent", + "TELEMETRY_ABSENT_REASON": "This cell has no ops cell.", + "OTEL_EXPORTER_OTLP_ENDPOINT": leftover, + })) + require.NoError(t, err) + require.False(t, destination.Available(), "a leftover endpoint must not turn an absent collector into an available one") + require.Empty(t, destination.URL) + require.Empty(t, destination.Endpoint) + require.Equal(t, "This cell has no ops cell.", destination.AbsentReason) + require.Equal(t, "OTEL_EXPORTER_OTLP_ENDPOINT", destination.Ignored) + notice := destination.IgnoredNotice() + require.Contains(t, notice, "OTEL_EXPORTER_OTLP_ENDPOINT is set but TELEMETRY_STATE is `absent`") + require.NotContains(t, notice, "hunter2", "the notice names the key, never its value") + }) + } +} + +// A blank leftover is nothing left over. +func TestTelemetryDestinationBlankLeftoversAreNotNoticed(t *testing.T) { + available, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, "TELEMETRY_ABSENT_REASON": " \n ", + })) + require.NoError(t, err) + require.Empty(t, available.IgnoredNotice()) + + absent, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "no collector", "OTEL_EXPORTER_OTLP_ENDPOINT": " ", + })) + require.NoError(t, err) + require.Empty(t, absent.IgnoredNotice()) +} + +// layered is what the platform hands a workload: each layer overrides the one +// below it key by key, so a key a higher layer does not set survives from the +// lower one. +func layered(layers ...map[string]string) func(string) string { + merged := map[string]string{} + for _, layer := range layers { + for key, value := range layer { + merged[key] = value + } + } + return group(merged) +} + +// The case this exists for: the module's local profile defaults to `absent` with +// a reason, and a deployed cell overrides the state and the endpoint but not the +// reason. The cell's answer wins, whichever way it points. +func TestTelemetryDestinationTheCellOverridesTheModuleDefaults(t *testing.T) { + moduleDefault := map[string]string{ + "TELEMETRY_STATE": "absent", + "TELEMETRY_ABSENT_REASON": "A local run has no cell collector.", + } + + t.Run("a cell with a collector overrides the local absent default", func(t *testing.T) { + destination, err := resolveTelemetryDestination(layered(moduleDefault, map[string]string{ + "TELEMETRY_STATE": "available", + "OTEL_EXPORTER_OTLP_ENDPOINT": collector, + })) + require.NoError(t, err) + require.True(t, destination.Available()) + require.Equal(t, collector, destination.URL) + require.Empty(t, destination.AbsentReason) + require.Equal(t, "TELEMETRY_ABSENT_REASON", destination.Ignored) + }) + + t.Run("a cell with no collector, over a default that had an endpoint, is absent", func(t *testing.T) { + destination, err := resolveTelemetryDestination(layered( + map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, + map[string]string{"TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "This cell has no ops cell."}, + )) + require.NoError(t, err) + require.False(t, destination.Available()) + require.Equal(t, "This cell has no ops cell.", destination.AbsentReason) + require.Equal(t, "OTEL_EXPORTER_OTLP_ENDPOINT", destination.Ignored) + }) + + t.Run("a cell that overrides only the state still has to supply what it requires", func(t *testing.T) { + _, err := resolveTelemetryDestination(layered(moduleDefault, map[string]string{"TELEMETRY_STATE": "available"})) + require.ErrorContains(t, err, "OTEL_EXPORTER_OTLP_ENDPOINT is not set", + "the module default's reason is not an endpoint") + + _, err = resolveTelemetryDestination(layered( + map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, + map[string]string{"TELEMETRY_STATE": "absent"}, + )) + require.ErrorContains(t, err, "TELEMETRY_ABSENT_REASON is not set", + "the module default's endpoint is not a reason") + }) +} + // A refusal quotes no part of an endpoint, which may carry credentials. func TestTelemetryDestinationRefusalDoesNotEchoCredentials(t *testing.T) { _, err := resolveTelemetryDestination(group(map[string]string{ @@ -228,6 +349,22 @@ func TestConfiguredTelemetryDestinationReadsTheObservabilityGroup(t *testing.T) require.Equal(t, "a local run has no cell collector", destination.AbsentReason) } +// Through the SDK's lookup, with the keys exactly as a deployed cell delivers +// them after the layers merge: the cell's state and endpoint, and the reason the +// module's local profile left behind. +func TestConfiguredTelemetryDestinationStateDecidesAcrossLayers(t *testing.T) { + const prefix = "CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__" + t.Setenv(prefix+"TELEMETRY_STATE", "available") + t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", collector) + t.Setenv(prefix+"TELEMETRY_ABSENT_REASON", "A local run has no cell collector.") + + destination, err := configuredTelemetryDestination() + require.NoError(t, err) + require.True(t, destination.Available()) + require.Equal(t, collector, destination.URL) + require.NotEmpty(t, destination.IgnoredNotice()) +} + // service.name is never a literal typed here: the environment wins, then the // service's own Codefly identity. func TestTelemetryResourceServiceName(t *testing.T) { diff --git a/module/services/auth-gateway/code/telemetry_metrics.go b/module/services/auth-gateway/code/telemetry_metrics.go index bcebfed60..114e1e107 100644 --- a/module/services/auth-gateway/code/telemetry_metrics.go +++ b/module/services/auth-gateway/code/telemetry_metrics.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "net/http" runtimemetrics "runtime/metrics" "time" @@ -22,17 +23,21 @@ type otelMetrics struct { } func enableOTELMetrics(ctx context.Context, destination telemetryDestination) (*otelMetrics, error) { - options := []otlpmetricgrpc.Option{otlpmetricgrpc.WithEndpoint(destination.Endpoint)} - if destination.Insecure { - // http://: plaintext on the wire, because the mesh supplies mTLS. An - // https:// endpoint takes the exporter's default, TLS. - options = append(options, otlpmetricgrpc.WithInsecure()) + if destination.URL == "" { + // WithEndpointURL("") would fall back to the exporter's own default, + // localhost:4317: a process that exports to nowhere and reports success. + return nil, errors.New("telemetry: no collector URL to export metrics to") } - exporter, err := otlpmetricgrpc.New(ctx, options...) + // The resource comes before the exporter, so a failure here leaves nothing + // to shut down: an exporter holds a client connection that only Shutdown + // releases. + res, err := currentTelemetryResource(ctx) if err != nil { return nil, err } - res, err := currentTelemetryResource(ctx) + // The exporter reads the transport off the URL's scheme itself: http:// is + // plaintext on the wire, because the mesh supplies mTLS, and https:// is TLS. + exporter, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithEndpointURL(destination.URL)) if err != nil { return nil, err } diff --git a/module/services/auth-gateway/code/telemetry_metrics_test.go b/module/services/auth-gateway/code/telemetry_metrics_test.go index 701c6c851..692e9eb8c 100644 --- a/module/services/auth-gateway/code/telemetry_metrics_test.go +++ b/module/services/auth-gateway/code/telemetry_metrics_test.go @@ -41,7 +41,7 @@ func TestAuthGatewayTelemetryExportsGatewayAndGRPCRED(t *testing.T) { t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.instance.id=auth-test-instance") t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") previousMeterProvider := otel.GetMeterProvider() - metrics, err := enableOTELMetrics(t.Context(), telemetryDestination{Endpoint: endpoint, Insecure: true}) + metrics, err := enableOTELMetrics(t.Context(), availableAt(t, "http", endpoint)) require.NoError(t, err) require.NotNil(t, metrics) t.Cleanup(func() { @@ -134,6 +134,65 @@ func authFloat64SumValue(request *collectormetricsv1.ExportMetricsServiceRequest return value } +// availableAt resolves the destination the way the process does, from the +// endpoint the platform would deliver, so these tests hand the exporter the URL +// it really receives rather than a hand-built struct. +func availableAt(t *testing.T, scheme, hostPort string) telemetryDestination { + t.Helper() + destination, err := resolveTelemetryDestination(group(map[string]string{ + "TELEMETRY_STATE": "available", + "OTEL_EXPORTER_OTLP_ENDPOINT": scheme + "://" + hostPort, + })) + require.NoError(t, err) + return destination +} + +// The exporter reads its transport off the URL's scheme, so an https:// URL is +// TLS and cannot talk to a plaintext receiver. A constant WithInsecure() would +// have let it. +func TestEnableOTELMetricsDerivesTransportFromTheURLScheme(t *testing.T) { + endpoint, capture := startAuthMetricCapture(t) + previousMeterProvider := otel.GetMeterProvider() + metrics, err := enableOTELMetrics(t.Context(), availableAt(t, "https", endpoint)) + require.NoError(t, err) + require.NotNil(t, metrics) + t.Cleanup(func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = metrics.Shutdown(shutdownCtx) // the flush it attempts is the failure under test + otel.SetMeterProvider(previousMeterProvider) + }) + + flushCtx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + require.Error(t, metrics.provider.ForceFlush(flushCtx), "a TLS client cannot export to a plaintext receiver") + select { + case <-capture.requests: + t.Fatal("an https:// destination reached a plaintext receiver") + default: + } +} + +// A destination with no collector URL is refused. WithEndpointURL("") would +// otherwise fall back to the exporter's own default, localhost:4317. +func TestEnableOTELMetricsRefusesADestinationWithoutAURL(t *testing.T) { + metrics, err := enableOTELMetrics(t.Context(), telemetryDestination{AbsentReason: "no collector"}) + require.ErrorContains(t, err, "no collector URL") + require.Nil(t, metrics) +} + +// A resource that cannot be built fails the whole call, and the call builds it +// before it creates the exporter: there is no exporter, and so no client +// connection, left running behind the failure. +func TestEnableOTELMetricsFailsWhenTheResourceCannotBeBuilt(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "an-attribute-with-no-value") + endpoint, _ := startAuthMetricCapture(t) + metrics, err := enableOTELMetrics(t.Context(), availableAt(t, "http", endpoint)) + require.Error(t, err) + require.Nil(t, metrics) +} + func startAuthMetricCapture(t *testing.T) (string, *authMetricCapture) { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") From 2991e7b8a40424c2a7cb7955cbb52ff15bb32aa1 Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:07:54 -0400 Subject: [PATCH 04/20] refactor(accounts,auth-gateway): resolve the listener ports without a default network (Refs #1022) Both services read their own listener ports through `WithDefaultNetwork()`, which makes the SDK answer an endpoint that does not resolve with a default network instance instead of an error. The next SDK release deletes that method, and its own test holds it deleted ("legacy means delete, not deprecate"): a port the runtime did not inject is an error, never a default, and a service that serves on a port nobody gave it is reachable from nowhere and reports healthy. This lands before the dependency bump so the bump is only a bump. - auth-gateway: the gRPC port is resolved with ResolveNetworkInstance and the process refuses to start without it, exactly as it already does for its REST port on the next line. - accounts: the gRPC port is required, for the same reason: it is what the process is. REST and Connect were optional in code but, behind the default network, never actually absent; they now resolve through one helper that, when an endpoint does not resolve, logs it by name and serves no listener for it, which is how this file already treats the module authority endpoint. The comment above that endpoint no longer needs to say it is the one query that avoids the default network. Behaviour is unchanged wherever the runtime injects the endpoint (auth-gateway already refused to start on a missing REST carrier, so that path is exercised). What changes is only the broken case: a missing gRPC endpoint is a refusal to start instead of a listener on a default port, and a missing REST or Connect endpoint is a logged error and no listener instead of a listener on a default port. Not exercised against a real cell: only a simulated deployed process, with the carriers set by hand. Classified as a fix at the place that owns the behaviour: these two call sites asked the SDK to invent a value, and the SDK stopped doing that. accounts' main.go still carries the template's "do not edit" banner, but it is authored: it already holds the module authority endpoint block and `codefly sync service` leaves it untouched. Co-Authored-By: Claude Opus 5.5 --- module/services/accounts/code/main.go | 40 +++++++++++++++++------ module/services/auth-gateway/code/main.go | 8 ++++- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/module/services/accounts/code/main.go b/module/services/accounts/code/main.go index 730776405..3a20c6f1d 100644 --- a/module/services/accounts/code/main.go +++ b/module/services/accounts/code/main.go @@ -34,6 +34,22 @@ import ( codefly "github.com/codefly-dev/sdk-go" ) +// resolvedPort is the port of one of this service's own endpoints, or nil when +// it does not resolve. A nil port serves no listener for the endpoint and says +// so loudly; it does not substitute a default one. +func resolvedPort(ctx context.Context, api string) *uint16 { + instance, err := codefly.For(ctx).API(api).ResolveNetworkInstance() + if err != nil { + wool.Get(ctx).In("main").Error( + "the endpoint did not resolve; no listener is served for it", + wool.Field("endpoint", api), + wool.ErrField(err), + ) + return nil + } + return shared.Pointer(instance.Port) +} + type Clean func() type Work func(ctx context.Context) (Clean, error) @@ -55,14 +71,19 @@ func main() { defer codefly.CatchPanic(ctx) - config := &adapters.Configuration{ - EndpointGrpcPort: codefly.For(ctx).WithDefaultNetwork().API(standards.GRPC).NetworkInstance().Port, - } - if net := codefly.For(ctx).WithDefaultNetwork().API(standards.REST).NetworkInstance(); net != nil { - config.EndpointHttpPort = shared.Pointer(net.Port) + // The SDK resolves an endpoint from the carrier the runtime injected or, in a + // local run, from the workspace's endpoint map, and never invents a port: an + // address that does not resolve is an error here, not a default. + // + // gRPC is what this process is. Without it there is nothing to start. + grpcNet, grpcErr := codefly.For(ctx).API(standards.GRPC).ResolveNetworkInstance() + if grpcErr != nil || grpcNet == nil { + panic(fmt.Sprintf("Codefly gRPC endpoint is unavailable: %v", grpcErr)) } - if net := codefly.For(ctx).WithDefaultNetwork().API(standards.CONNECT).NetworkInstance(); net != nil { - config.EndpointConnectPort = shared.Pointer(net.Port) + config := &adapters.Configuration{ + EndpointGrpcPort: grpcNet.Port, + EndpointHttpPort: resolvedPort(ctx, standards.REST), + EndpointConnectPort: resolvedPort(ctx, standards.CONNECT), } // The module authority endpoint is declared in service.codefly.yaml, and a // topology that omits it — or gives it a port another listener already @@ -76,9 +97,8 @@ func main() { // accounts would crashloop and take the tenant surfaces, the internal tier // and every service that authenticates through them down with it. // - // This query deliberately does not use WithDefaultNetwork(), so resolution - // never invents a port; the only outcomes are the injected address or this - // error path. + // Like the two above, this query never invents a port: the only outcomes are + // the injected address or this error path. if authority, resolveErr := codefly.For(ctx).Endpoint(business.ModuleAuthorityEndpoint).API(standards.GRPC).ResolveNetworkInstance(); resolveErr != nil { wool.Get(ctx).In("main").Error( "the module authority endpoint did not resolve; no module authority listener is served", diff --git a/module/services/auth-gateway/code/main.go b/module/services/auth-gateway/code/main.go index 4ea768fe6..77e0471c7 100644 --- a/module/services/auth-gateway/code/main.go +++ b/module/services/auth-gateway/code/main.go @@ -41,7 +41,13 @@ func main() { panic(err) } - grpcPort := codefly.For(ctx).WithDefaultNetwork().API(standards.GRPC).NetworkInstance().Port + // The SDK resolves an endpoint from the carrier the runtime injected or, in a + // local run, from the workspace's endpoint map, and never invents a port. + grpcNet, grpcNetErr := codefly.For(ctx).API(standards.GRPC).ResolveNetworkInstance() + if grpcNetErr != nil || grpcNet == nil { + panic(fmt.Sprintf("Codefly gRPC endpoint is unavailable: %v", grpcNetErr)) + } + grpcPort := grpcNet.Port var httpPort uint16 httpNet, httpNetErr := codefly.For(ctx).API(standards.REST).ResolveNetworkInstance() if httpNetErr != nil || httpNet == nil { From 2720aa815a72bc9f546a25b05aa46f53d97292ab Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:08:38 -0400 Subject: [PATCH 05/20] chore(deps): bump codefly core to v0.13.0 and sdk-go to v0.3.1 (Refs #1022) Core v0.13.0 carries the log/trace correlation that #1022 waits on, so accounts and auth-gateway now consume it. Each Go module was moved with the repository's bump verb, `codefly agent deps --dir --pin v0.13.0` (which pins every lock the module owns and verifies the standalone build), and `go mod tidy` leaves every module unchanged afterwards. Modules that pin core and moved: the root module agent, `module/tools`, accounts, auth-gateway, and `qualification/module-authority` and the store migrator, which only carry it indirectly. `libraries/source-read-sdk/go` does not depend on core. sdk-go moves with it, v0.2.1-0.20260926212413 -> v0.3.1, in accounts, auth-gateway and the store migrator, because it is not optional: the old sdk-go uses `resources.VisibilityExternal`, which core removed before v0.13.0, so no sdk-go older than v0.3.1 builds against any core this change could pin. v0.3.1 itself requires core v0.13.0, which makes v0.13.0 the pair upstream tested. Its one source-level break for this repository, the deleted `WithDefaultNetwork`, is handled in the commit before this one. Why not v0.14.0, which was the target: v0.14.0 builds, but it refuses a module interface or endpoint that authors `allow-modules`, since an allow-list is derived from the consumers' declared dependencies and never written by the module it grants. This module authors `allow-modules: ["*"]` in `module.codefly.yaml` and in the accounts, auth-gateway and vault manifests, so `TestGeneratedCodeflyAndNetworkManifestsParseStrictly` fails to load them under v0.14.0 and passes under v0.13.0. Moving those manifests to derived allow-lists is its own change, with its own render consequences for composing workspaces, and is not made here. `sdk-go/workcontext` stays on its current pseudo-version in accounts and auth-gateway. The newer workcontext replaces the signing API this repository imports (`WorkContextSigner`, `WorkContextVerifier`, `WorkContextToken` and the header constants), a migration of its own that touches most of accounts' Work Context handling. Tested with the generated telemetry stubs present: `go vet` and `go test` in accounts (pure suite), auth-gateway, `module/tools`, the root module and `qualification/module-authority`, `go vet` in the store migrator and the source-read SDK; every generator and `codefly sync` rerun leaves the tree unchanged; the contracts check with the CI-pinned CLI 0.1.171. Not exercised: `codefly run` of the graph, a deployed cell, or the accounts database suites. Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- module/services/accounts/code/go.mod | 4 ++-- module/services/accounts/code/go.sum | 8 ++++---- module/services/auth-gateway/code/go.mod | 4 ++-- module/services/auth-gateway/code/go.sum | 8 ++++---- module/services/store/code/go.mod | 4 ++-- module/services/store/code/go.sum | 8 ++++---- module/tools/go.mod | 2 +- module/tools/go.sum | 4 ++-- qualification/module-authority/go.mod | 2 +- qualification/module-authority/go.sum | 4 ++-- 12 files changed, 27 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 575a0b1cd..12e08b125 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/agents/modules/saas-starter go 1.27.0 require ( - github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 + github.com/codefly-dev/core v0.13.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index d1e38c6b3..d4703ab21 100644 --- a/go.sum +++ b/go.sum @@ -28,8 +28,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 h1:/vzymsu5B9NWwdhWRtlU+68VcVEVDmeZNChRCXAUwlE= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683/go.mod h1:SVF+t040X+9pAXAqBnNE/JkXwmw9peVuKnHKFSOzyzk= +github.com/codefly-dev/core v0.13.0 h1:FiphttBZRrKXEWoCZD7JpUB6oVuBCoqtAc06QeNdYWY= +github.com/codefly-dev/core v0.13.0/go.mod h1:rp4YZkCJEOufliWQAgK3sv8YlO7Xcva7+cN7hIlAR0I= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= diff --git a/module/services/accounts/code/go.mod b/module/services/accounts/code/go.mod index a8374adaf..d18e4ad9a 100644 --- a/module/services/accounts/code/go.mod +++ b/module/services/accounts/code/go.mod @@ -8,9 +8,9 @@ require ( connectrpc.com/connect v1.21.0 connectrpc.com/otelconnect v0.9.0 github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc - github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 + github.com/codefly-dev/core v0.13.0 github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba - github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e + github.com/codefly-dev/sdk-go v0.3.1 github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260926212413-b91ce91eb23e github.com/codefly-dev/service-postgres/libs/go v0.0.0-20260929200903-bae6c31c7211 github.com/codefly-dev/service-redis/cache v0.2.0 diff --git a/module/services/accounts/code/go.sum b/module/services/accounts/code/go.sum index bb1012c57..3d200f682 100644 --- a/module/services/accounts/code/go.sum +++ b/module/services/accounts/code/go.sum @@ -58,14 +58,14 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc h1:olDnVJ1A1Retn0z9A59YMwDRmDLgkV2y1lbl+HzNrj8= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc/go.mod h1:VWPYY3FH4te7SzdN2xl9odNpWidJ1Grl/Hjs1SNoEF8= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 h1:/vzymsu5B9NWwdhWRtlU+68VcVEVDmeZNChRCXAUwlE= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683/go.mod h1:SVF+t040X+9pAXAqBnNE/JkXwmw9peVuKnHKFSOzyzk= +github.com/codefly-dev/core v0.13.0 h1:FiphttBZRrKXEWoCZD7JpUB6oVuBCoqtAc06QeNdYWY= +github.com/codefly-dev/core v0.13.0/go.mod h1:rp4YZkCJEOufliWQAgK3sv8YlO7Xcva7+cN7hIlAR0I= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba h1:6nFaDYsLyA5x3WiGAk6pMI3c0naqxiPeaTHMqRPvbVs= github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba/go.mod h1:OQDNwz4rpEGfq7VC/FjvmQGlo9H8gH8fIDcZhKIe8iQ= -github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e h1:V8YSUvAm0QQgHY9V0QmHiHuvf+beHnI10D6cJLWB2eI= -github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e/go.mod h1:X0JAk/gnr6ZcjuPsM+5jvisqY4iyTC1SeVac93iWX8A= +github.com/codefly-dev/sdk-go v0.3.1 h1:ljKwrfHIiESV+R/MXO3a7XTdKwFNaY6vdw0+8YAo9g8= +github.com/codefly-dev/sdk-go v0.3.1/go.mod h1:vWrT2zXNMuCrctTAHMBYWuGRkfr0OP3IXATyuJ6iYvE= github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260926212413-b91ce91eb23e h1:NlcOB8YN/372aNAGGkkFnT0du++qCFyilYVZLuqkogI= github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260926212413-b91ce91eb23e/go.mod h1:BbqUHTJEnmH3oxYblfjS3Oh9AUw14YaEFVV4Y19yQPY= github.com/codefly-dev/service-postgres/libs/go v0.0.0-20260929200903-bae6c31c7211 h1:4KAPx8haCA/h4Kx5mB7D0rMriwLLKiOW9G9mMWuiQDw= diff --git a/module/services/auth-gateway/code/go.mod b/module/services/auth-gateway/code/go.mod index d9c26a210..25805db5f 100644 --- a/module/services/auth-gateway/code/go.mod +++ b/module/services/auth-gateway/code/go.mod @@ -4,8 +4,8 @@ go 1.27.0 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 - github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 - github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e + github.com/codefly-dev/core v0.13.0 + github.com/codefly-dev/sdk-go v0.3.1 github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260926212413-b91ce91eb23e github.com/envoyproxy/go-control-plane/envoy v1.39.0 github.com/golang-jwt/jwt/v5 v5.3.1 diff --git a/module/services/auth-gateway/code/go.sum b/module/services/auth-gateway/code/go.sum index 6be460d10..2e176b4a0 100644 --- a/module/services/auth-gateway/code/go.sum +++ b/module/services/auth-gateway/code/go.sum @@ -42,12 +42,12 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 h1:/vzymsu5B9NWwdhWRtlU+68VcVEVDmeZNChRCXAUwlE= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683/go.mod h1:SVF+t040X+9pAXAqBnNE/JkXwmw9peVuKnHKFSOzyzk= +github.com/codefly-dev/core v0.13.0 h1:FiphttBZRrKXEWoCZD7JpUB6oVuBCoqtAc06QeNdYWY= +github.com/codefly-dev/core v0.13.0/go.mod h1:rp4YZkCJEOufliWQAgK3sv8YlO7Xcva7+cN7hIlAR0I= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= -github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e h1:V8YSUvAm0QQgHY9V0QmHiHuvf+beHnI10D6cJLWB2eI= -github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e/go.mod h1:X0JAk/gnr6ZcjuPsM+5jvisqY4iyTC1SeVac93iWX8A= +github.com/codefly-dev/sdk-go v0.3.1 h1:ljKwrfHIiESV+R/MXO3a7XTdKwFNaY6vdw0+8YAo9g8= +github.com/codefly-dev/sdk-go v0.3.1/go.mod h1:vWrT2zXNMuCrctTAHMBYWuGRkfr0OP3IXATyuJ6iYvE= github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260926212413-b91ce91eb23e h1:NlcOB8YN/372aNAGGkkFnT0du++qCFyilYVZLuqkogI= github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260926212413-b91ce91eb23e/go.mod h1:BbqUHTJEnmH3oxYblfjS3Oh9AUw14YaEFVV4Y19yQPY= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= diff --git a/module/services/store/code/go.mod b/module/services/store/code/go.mod index 60d84e43e..05f10868b 100644 --- a/module/services/store/code/go.mod +++ b/module/services/store/code/go.mod @@ -3,7 +3,7 @@ module store-migrator go 1.27.0 require ( - github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e + github.com/codefly-dev/sdk-go v0.3.1 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/lib/pq v1.12.3 ) @@ -16,7 +16,7 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 // indirect + github.com/codefly-dev/core v0.13.0 // indirect github.com/go-openapi/analysis v1.0.0 // indirect github.com/go-openapi/errors v0.22.8 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect diff --git a/module/services/store/code/go.sum b/module/services/store/code/go.sum index 99e8ef195..adfc358a3 100644 --- a/module/services/store/code/go.sum +++ b/module/services/store/code/go.sum @@ -30,12 +30,12 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 h1:/vzymsu5B9NWwdhWRtlU+68VcVEVDmeZNChRCXAUwlE= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683/go.mod h1:SVF+t040X+9pAXAqBnNE/JkXwmw9peVuKnHKFSOzyzk= +github.com/codefly-dev/core v0.13.0 h1:FiphttBZRrKXEWoCZD7JpUB6oVuBCoqtAc06QeNdYWY= +github.com/codefly-dev/core v0.13.0/go.mod h1:rp4YZkCJEOufliWQAgK3sv8YlO7Xcva7+cN7hIlAR0I= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= -github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e h1:V8YSUvAm0QQgHY9V0QmHiHuvf+beHnI10D6cJLWB2eI= -github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e/go.mod h1:X0JAk/gnr6ZcjuPsM+5jvisqY4iyTC1SeVac93iWX8A= +github.com/codefly-dev/sdk-go v0.3.1 h1:ljKwrfHIiESV+R/MXO3a7XTdKwFNaY6vdw0+8YAo9g8= +github.com/codefly-dev/sdk-go v0.3.1/go.mod h1:vWrT2zXNMuCrctTAHMBYWuGRkfr0OP3IXATyuJ6iYvE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= diff --git a/module/tools/go.mod b/module/tools/go.mod index d694ed99c..18f97c6b8 100644 --- a/module/tools/go.mod +++ b/module/tools/go.mod @@ -4,7 +4,7 @@ go 1.27.0 require ( github.com/Masterminds/semver/v3 v3.5.0 - github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 + github.com/codefly-dev/core v0.13.0 github.com/yoheimuta/go-protoparser/v4 v4.14.2 golang.org/x/tools v0.49.0 google.golang.org/protobuf v1.36.12 diff --git a/module/tools/go.sum b/module/tools/go.sum index 9a67efddf..46f722672 100644 --- a/module/tools/go.sum +++ b/module/tools/go.sum @@ -28,8 +28,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 h1:/vzymsu5B9NWwdhWRtlU+68VcVEVDmeZNChRCXAUwlE= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683/go.mod h1:SVF+t040X+9pAXAqBnNE/JkXwmw9peVuKnHKFSOzyzk= +github.com/codefly-dev/core v0.13.0 h1:FiphttBZRrKXEWoCZD7JpUB6oVuBCoqtAc06QeNdYWY= +github.com/codefly-dev/core v0.13.0/go.mod h1:rp4YZkCJEOufliWQAgK3sv8YlO7Xcva7+cN7hIlAR0I= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= diff --git a/qualification/module-authority/go.mod b/qualification/module-authority/go.mod index bd8a5454d..2a311147d 100644 --- a/qualification/module-authority/go.mod +++ b/qualification/module-authority/go.mod @@ -9,7 +9,7 @@ require ( require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect - github.com/codefly-dev/core v0.3.32 // indirect + github.com/codefly-dev/core v0.13.0 // indirect github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260927024316-b3fb40d43099 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect diff --git a/qualification/module-authority/go.sum b/qualification/module-authority/go.sum index bd1f7e2a8..379eb172b 100644 --- a/qualification/module-authority/go.sum +++ b/qualification/module-authority/go.sum @@ -2,8 +2,8 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-202608252041 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs= connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= -github.com/codefly-dev/core v0.3.32 h1:zVgGAljUERRjHe5ONhI2uBuQg0eayZzkFSm0murp5NI= -github.com/codefly-dev/core v0.3.32/go.mod h1:f5LY9gRu5lpBQ0tNKujP0q6MF9K8fUnbYvItuHaUWlY= +github.com/codefly-dev/core v0.13.0 h1:FiphttBZRrKXEWoCZD7JpUB6oVuBCoqtAc06QeNdYWY= +github.com/codefly-dev/core v0.13.0/go.mod h1:rp4YZkCJEOufliWQAgK3sv8YlO7Xcva7+cN7hIlAR0I= github.com/codefly-dev/saas-sdk-go v0.0.3-0.20260927130902-fed59a51306c h1:xZHqwC4BG2+N8Rz8euD6pF60f0QuHVljHVkPXShS2zU= github.com/codefly-dev/saas-sdk-go v0.0.3-0.20260927130902-fed59a51306c/go.mod h1:o/Gkv2g9T9ObjHxg54Dafp6/yPJDEgSSrh+pyg4SD44= github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260927024316-b3fb40d43099 h1:MEzUboHy6HzBex1VoAOo3JqBgjTjSYs3jQGyqIwHOhQ= From badfb6c8e3423e00017ab551e3eb77c83e7c4a65 Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:08:54 -0400 Subject: [PATCH 06/20] fix(accounts,auth-gateway): restore the generated telemetry client stubs the generator owns (Refs #1022) The service-removal commit deleted `external/saas-starter/telemetry/` from accounts and auth-gateway by hand. Both files are generator output (`Code generated by protoc-gen-go. DO NOT EDIT.`), and this repository does not hand-edit generated code, deletion included. They are restored byte for byte from main, with no other change. They are orphaned: no dependency block declares the telemetry service any more, and nothing imports the package. `codefly sync service` does not prune bindings for a dependency that is no longer declared, so a sync leaves them where they are, and the tree stays clean after every generator and sync rerun with them present. With them present, `go vet` and `go test` in accounts (pure suite) and auth-gateway, the contracts check with the CI-pinned CLI 0.1.171, the module-package validation and archive determinism, and the node gates all pass, so there is no failing check to justify removing them. The generator owns them and will prune them once it learns to; a draft issue against the go-grpc agent describes the gap. Co-Authored-By: Claude Opus 5.5 --- .../saas-starter_telemetry_grpc.pb.go | 60 +++++++++++++++++++ .../saas-starter_telemetry_grpc.pb.go | 60 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 module/services/accounts/code/external/saas-starter/telemetry/saas-starter_telemetry_grpc.pb.go create mode 100644 module/services/auth-gateway/code/external/saas-starter/telemetry/saas-starter_telemetry_grpc.pb.go diff --git a/module/services/accounts/code/external/saas-starter/telemetry/saas-starter_telemetry_grpc.pb.go b/module/services/accounts/code/external/saas-starter/telemetry/saas-starter_telemetry_grpc.pb.go new file mode 100644 index 000000000..689bf2157 --- /dev/null +++ b/module/services/accounts/code/external/saas-starter/telemetry/saas-starter_telemetry_grpc.pb.go @@ -0,0 +1,60 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: saas-starter_telemetry_grpc.proto + +package telemetry + +import ( + reflect "reflect" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_saas_starter_telemetry_grpc_proto protoreflect.FileDescriptor + +const file_saas_starter_telemetry_grpc_proto_rawDesc = "" + + "\n" + + "!saas-starter_telemetry_grpc.protoBhB\x1dSaasStarterTelemetryGrpcProtoP\x01ZEgithub.com/codefly-dev/cli/pkg/builder/clients/saas-starter/telemetry" + +var file_saas_starter_telemetry_grpc_proto_goTypes = []any{} +var file_saas_starter_telemetry_grpc_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_saas_starter_telemetry_grpc_proto_init() } +func file_saas_starter_telemetry_grpc_proto_init() { + if File_saas_starter_telemetry_grpc_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_starter_telemetry_grpc_proto_rawDesc), len(file_saas_starter_telemetry_grpc_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_saas_starter_telemetry_grpc_proto_goTypes, + DependencyIndexes: file_saas_starter_telemetry_grpc_proto_depIdxs, + }.Build() + File_saas_starter_telemetry_grpc_proto = out.File + file_saas_starter_telemetry_grpc_proto_goTypes = nil + file_saas_starter_telemetry_grpc_proto_depIdxs = nil +} diff --git a/module/services/auth-gateway/code/external/saas-starter/telemetry/saas-starter_telemetry_grpc.pb.go b/module/services/auth-gateway/code/external/saas-starter/telemetry/saas-starter_telemetry_grpc.pb.go new file mode 100644 index 000000000..689bf2157 --- /dev/null +++ b/module/services/auth-gateway/code/external/saas-starter/telemetry/saas-starter_telemetry_grpc.pb.go @@ -0,0 +1,60 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: saas-starter_telemetry_grpc.proto + +package telemetry + +import ( + reflect "reflect" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_saas_starter_telemetry_grpc_proto protoreflect.FileDescriptor + +const file_saas_starter_telemetry_grpc_proto_rawDesc = "" + + "\n" + + "!saas-starter_telemetry_grpc.protoBhB\x1dSaasStarterTelemetryGrpcProtoP\x01ZEgithub.com/codefly-dev/cli/pkg/builder/clients/saas-starter/telemetry" + +var file_saas_starter_telemetry_grpc_proto_goTypes = []any{} +var file_saas_starter_telemetry_grpc_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_saas_starter_telemetry_grpc_proto_init() } +func file_saas_starter_telemetry_grpc_proto_init() { + if File_saas_starter_telemetry_grpc_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_starter_telemetry_grpc_proto_rawDesc), len(file_saas_starter_telemetry_grpc_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_saas_starter_telemetry_grpc_proto_goTypes, + DependencyIndexes: file_saas_starter_telemetry_grpc_proto_depIdxs, + }.Build() + File_saas_starter_telemetry_grpc_proto = out.File + file_saas_starter_telemetry_grpc_proto_goTypes = nil + file_saas_starter_telemetry_grpc_proto_depIdxs = nil +} From 3532058682ac596b906a334c095e4a98d7b428a1 Mon Sep 17 00:00:00 2001 From: Cristopher Fiorini <301773486+crisfiorini-oss@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:06:14 -0400 Subject: [PATCH 07/20] fix(vault): declare its endpoint private within the module --- .../generated/service-topology.json | 2 +- module/services/vault/service.codefly.yaml | 3 +- vault_binding_test.go | 42 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/module/deployment/generated/service-topology.json b/module/deployment/generated/service-topology.json index 6164033d8..360262947 100644 --- a/module/deployment/generated/service-topology.json +++ b/module/deployment/generated/service-topology.json @@ -149,7 +149,7 @@ { "name": "http", "api": "CODEFLY_API_HTTP", - "visibility": "ENDPOINT_VISIBILITY_MODULE", + "visibility": "ENDPOINT_VISIBILITY_PRIVATE", "port": 8200 } ] diff --git a/module/services/vault/service.codefly.yaml b/module/services/vault/service.codefly.yaml index 75e87fb52..12bfc39a6 100644 --- a/module/services/vault/service.codefly.yaml +++ b/module/services/vault/service.codefly.yaml @@ -11,8 +11,7 @@ secret-service-configurations: - key: vault_token endpoints: - name: http - visibility: internal - allow-modules: ["*"] + visibility: private spec: transit-key: api-keys deployment: diff --git a/vault_binding_test.go b/vault_binding_test.go index 405f57259..a3992f9ce 100644 --- a/vault_binding_test.go +++ b/vault_binding_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/resources" "gopkg.in/yaml.v3" ) @@ -31,6 +33,46 @@ const ( meshGroupDefaults = "module/configurations/local/internal-transport.env" ) +// Vault is a dependency of accounts within this module, never an exported +// endpoint. Declare that boundary on the service too: older supported CLIs +// make an omitted module-interface endpoint private without removing its +// service's allow-list, which newer SDKs correctly reject as invalid. +func TestVaultEndpointIsPrivateAndResolvesOnlyWithinItsModule(t *testing.T) { + t.Parallel() + data, err := os.ReadFile(vaultServiceManifest) + if err != nil { + t.Fatal(err) + } + var service resources.Service + if err := yaml.Unmarshal(data, &service); err != nil { + t.Fatal(err) + } + if len(service.Endpoints) != 1 || service.Endpoints[0].Name != "http" { + t.Fatalf("unexpected vault endpoints: %#v", service.Endpoints) + } + endpoint := service.Endpoints[0] + if endpoint.Visibility != resources.VisibilityPrivate || len(endpoint.AllowModules) != 0 { + t.Fatalf("vault/http must declare private visibility without allow-modules; got %q, %v", endpoint.Visibility, endpoint.AllowModules) + } + endpoint.Module, endpoint.Service = "saas-starter", "vault" + declaration, err := endpoint.Proto() + if err != nil { + t.Fatal(err) + } + dependencies := []*resources.ServiceDependency{{ + Module: "saas-starter", Name: "vault", + Endpoints: []*resources.EndpointReference{{Name: "http"}}, + }} + mappings := []*basev0.NetworkMapping{{Endpoint: declaration}} + resolved, err := resources.ResolveDependencyNetworkMappings("saas-starter", dependencies, mappings) + if err != nil || len(resolved) != 1 { + t.Fatalf("same-module vault dependency: resolved %d mappings, error %v", len(resolved), err) + } + if _, err := resources.ResolveDependencyNetworkMappings("example", dependencies, mappings); err == nil { + t.Fatal("vault/http was handed to a different module") + } +} + // vaultDurableRenderFloor is the first vault agent release whose deployed render // is durable: `vault server` with integrated raft storage on a retained // PersistentVolumeClaim, auto-unsealed by the seal the environment supplies, From 48ced8dd6c753e3a70018e451ec82fea65e1acf8 Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:51:39 -0400 Subject: [PATCH 08/20] fix(telemetry): require platform state outside local runtimes Remove TELEMETRY_STATE from module defaults that deployed cells inherit. Infer an absent collector only when the SDK identifies a local runtime, and retain refusal of explicit invalid or incomplete configuration. Cover shipped defaults, both runtime modes and startup checks in both producers; update the operator docs and module breaking-change notice. --- .../skills/run-the-starter-locally/SKILL.md | 14 +-- DOGFOODING.md | 4 +- module/FEATURES.md | 11 ++- module/configurations/local/observability.env | 7 +- module/module.package.codefly.yaml | 2 +- .../accounts/code/telemetry_destination.go | 15 ++- .../code/telemetry_destination_test.go | 99 ++++++++++++++----- .../accounts/code/telemetry_metrics_test.go | 2 +- module/services/accounts/code/work.go | 14 +-- module/services/auth-gateway/code/main.go | 4 +- .../code/telemetry_destination.go | 15 ++- .../code/telemetry_destination_test.go | 90 +++++++++++++---- .../code/telemetry_metrics_test.go | 2 +- 13 files changed, 200 insertions(+), 79 deletions(-) diff --git a/.claude/skills/run-the-starter-locally/SKILL.md b/.claude/skills/run-the-starter-locally/SKILL.md index 1f96ddc95..2e9eb0259 100644 --- a/.claude/skills/run-the-starter-locally/SKILL.md +++ b/.claude/skills/run-the-starter-locally/SKILL.md @@ -18,13 +18,13 @@ codefly run service --fixture dev-admin - No TTY (CI, pipes, MCP) auto-enables `--headless`. - Docker must be running. `codefly doctor` checks prerequisites; `codefly clear` reaps stray processes and containers between runs. -- A local run has no cell collector, and the module ships none. The `observability` - group (`configurations/local/observability.env`) says so — `TELEMETRY_STATE=absent` - with a reason — and accounts and auth-gateway log the reason once, send traces to - wool's stdout tracer and export no metrics. They refuse to start, naming - `TELEMETRY_STATE`, when that group did not arrive: a composing workspace that - overrode it without the keys, or a `local-dogfood` run without its copied - `observability.env`. +- A local run has no cell collector, and the module ships none. The module's + `configurations/local/observability.env` supplies a reason but no + `TELEMETRY_STATE`: those defaults also reach deployed cells. Only a runtime the + SDK identifies as local infers `absent` when the state is missing, logs the + reason once, sends traces to wool's stdout tracer and exports no metrics. + Outside that runtime, a missing state refuses startup. The `local-dogfood` + profile must still copy its explicit state from `observability.env.example`. For a real external identity provider and the production-grade provider stack, use the `local-dogfood` environment and its configuration groups: diff --git a/DOGFOODING.md b/DOGFOODING.md index f6dc63229..5085e0f1a 100644 --- a/DOGFOODING.md +++ b/DOGFOODING.md @@ -219,7 +219,7 @@ the same Codefly-managed graph. For each of `email` (Resend), `configurations/local-dogfood/.env.example` and `.secret.env.example` to their Git-ignored names and fill them in ([LOCAL_DOGFOODING.md](./LOCAL_DOGFOODING.md#configure-providers-through-codefly)). -Keep `observability.env.example` as it is for a local run with no collector (`TELEMETRY_STATE=absent`). +Keep `observability.env.example` as it is for a `local-dogfood` run with no collector (`TELEMETRY_STATE=absent`). The plain `local` runtime infers `absent` when the state is missing; the module's local defaults do not declare it. Turnstile's published test keys drive the three abuse-protection cases: | Case | `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | `TURNSTILE_SECRET_KEY` | @@ -239,7 +239,7 @@ codefly run service --env local-dogfood - [ ] Grant analytics consent, navigate through onboarding, and verify bounded browser plus durable backend events in PostHog. - [ ] Withdraw analytics consent or log out. Browser capture stops immediately and identity resets. - [ ] Trigger controlled browser and backend errors. Sentry correlates release and environment without creating performance transactions. -- [ ] With `TELEMETRY_STATE=absent`, check that accounts and auth-gateway log the absent reason once at startup, then exercise login/onboarding and observe wool's stdout trace output (metrics are not exported; logs are on stdout). Remove `TELEMETRY_STATE` and confirm both services refuse to start, naming it. +- [ ] With `TELEMETRY_STATE=absent`, check that accounts and auth-gateway log the absent reason once at startup, then exercise login/onboarding and observe wool's stdout trace output (metrics are not exported; logs are on stdout). Remove `TELEMETRY_STATE` in `local-dogfood` and confirm both services refuse to start, naming it. In the plain `local` runtime, a missing state instead starts with an absent collector. - [ ] Switch Turnstile to the **fail** keys. Registration and waitlist submission fail without database writes. - [ ] Switch Turnstile to the **replay** keys. The first deterministic verification follows Cloudflare's fixture behavior; replay rejection leaves state unchanged. diff --git a/module/FEATURES.md b/module/FEATURES.md index 15c4b824e..c38d15d93 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -655,7 +655,7 @@ Environment variables consumed by the api: | `POSTHOG_API_HOST` | Separate PostHog management/deletion origin | | `ERROR_TRACKING_MODE` | Explicit `disabled` or `sentry`; rejects partial config | | `SENTRY_DSN` | Server Sentry DSN, required in Sentry mode | -| `TELEMETRY_STATE` | `observability` group, delivered by the platform: `available` or `absent`, never empty; accounts and auth-gateway refuse to start without it | +| `TELEMETRY_STATE` | `observability` group, delivered by the platform: `available` or `absent`; missing state refuses startup except in a local runtime, where it means `absent` | | `OTEL_EXPORTER_OTLP_ENDPOINT` | `observability` group: the cell collector's OTLP/gRPC address, required when `available` and ignored when `absent`; `http://` is plaintext on the wire because the mesh supplies mTLS, `https://` is TLS | | `TELEMETRY_ABSENT_REASON` | `observability` group: why the cell has no collector, required when `absent` and ignored when `available`; logged once at startup | | `ABUSE_PROTECTION_MODE` | Explicit `disabled` or `turnstile` | @@ -672,9 +672,12 @@ then uses wool's stdout tracer for traces. The state decides: a cell's values override the module's local defaults one key at a time, so a deployed cell that says `available` with an endpoint can still carry the `absent` reason the local profile left behind. The key the state does not use is ignored, and a startup -warning names it once. Anything else — the state missing or unknown, `available` -without an endpoint, or `absent` without a reason — refuses to start, because a -group that did not arrive is not a cell without a collector. An `https://` +warning names it once. Module defaults never declare `TELEMETRY_STATE`, because +they also reach deployed cells. Only a local runtime infers `absent` from a +missing state; elsewhere missing state refuses startup, because a group that +did not arrive is not a cell without a collector. Unknown state, `available` +without an endpoint, or explicit `absent` without a reason also refuses startup, +including locally. An `https://` endpoint is refused at startup for now: wool's OTLP tracer dials plaintext only, so it would be sent in the clear and reported as TLS. Metrics leave by OTLP push alone — decided 2026-10-06, because the cell's diff --git a/module/configurations/local/observability.env b/module/configurations/local/observability.env index 4d7ddcb97..bce6d701d 100644 --- a/module/configurations/local/observability.env +++ b/module/configurations/local/observability.env @@ -1,6 +1,6 @@ # Where accounts and auth-gateway send traces and metrics, or that they send none. # The platform delivers exactly one of two answers, and a process that gets neither -# refuses to start rather than guess: +# refuses to start outside a local runtime rather than guess: # # TELEMETRY_STATE=available the cell has a collector. Also sets # OTEL_EXPORTER_OTLP_ENDPOINT to its OTLP/gRPC address, @@ -15,6 +15,7 @@ # process ignores the key its state does not use and logs that once. # # A local run has no cell, so it has no collector: traces go to wool's stdout tracer -# and metrics are not exported. Logs always go to stdout. -TELEMETRY_STATE=absent +# and metrics are not exported. Logs always go to stdout. Do not declare a state +# here: these defaults also reach deployed cells. Only a local runtime infers +# `absent` when the platform has not delivered TELEMETRY_STATE. TELEMETRY_ABSENT_REASON=A local run has no cell collector; traces go to the stdout tracer and metrics are not exported. diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index eacfad85f..2a146daf8 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -98,7 +98,7 @@ migrations: breaking-changes: - Consumers resolve the immutable package into a disposable projection instead of editing Starter-owned files. - "Audit event types are namespaced under `saas.*` (`auth.login` -> `saas.auth.login`). The wire value of `audit_events.event_type`, the `X-Webhook-Event` header, and webhook routing keys all change; migration 116 rewrites stored history and existing subscriptions in place, so a live subscriber keeps firing, but a consumer that re-creates a subscription from a hardcoded pre-namespace name, or emits one through `ModuleEmitAuditEvent`, is now rejected. No alias layer." - - "The `telemetry` service is removed: the module no longer ships an OTLP gateway, and the cell's collector takes its place. `accounts` and `auth-gateway` read where it is from the `observability` configuration group — `TELEMETRY_STATE` is `available` with `OTEL_EXPORTER_OTLP_ENDPOINT` (an `http://` endpoint is plaintext on the wire because the mesh supplies mTLS; `https://` is TLS), or `absent` with `TELEMETRY_ABSENT_REASON` — and refuse to start when the group does not say which. `OBSERVABILITY_EXPORTER` and `OTEL_EXPORTER_OTLP_HEADERS` no longer mean anything, the `/metrics` scrape endpoint of both services is gone (metrics leave by OTLP push only), and `service.name` is no longer fixed in code: `OTEL_SERVICE_NAME` or `OTEL_RESOURCE_ATTRIBUTES` sets it, else the service's own Codefly identity. A consumer that depended on the `telemetry` service, or that overrides the `observability` group, must move to the keys above." + - "The `telemetry` service is removed: the module no longer ships an OTLP gateway, and the cell's collector takes its place. `accounts` and `auth-gateway` read where it is from the `observability` configuration group — `TELEMETRY_STATE` is `available` with `OTEL_EXPORTER_OTLP_ENDPOINT` (an `http://` endpoint is plaintext on the wire because the mesh supplies mTLS; `https://` is TLS), or `absent` with `TELEMETRY_ABSENT_REASON` — and refuse to start outside a local runtime when the group does not say which. Module defaults do not declare TELEMETRY_STATE; only a local runtime infers absent when the state is missing. `OBSERVABILITY_EXPORTER` and `OTEL_EXPORTER_OTLP_HEADERS` no longer mean anything, the `/metrics` scrape endpoint of both services is gone (metrics leave by OTLP push only), and `service.name` is no longer fixed in code: `OTEL_SERVICE_NAME` or `OTEL_RESOURCE_ATTRIBUTES` sets it, else the service's own Codefly identity. A consumer that depended on the `telemetry` service, or that overrides the `observability` group, must move to the keys above." reserved-namespaces: - "@codefly/saas-" - "@codefly-dev/saas-" diff --git a/module/services/accounts/code/telemetry_destination.go b/module/services/accounts/code/telemetry_destination.go index c752d82fd..cecf41049 100644 --- a/module/services/accounts/code/telemetry_destination.go +++ b/module/services/accounts/code/telemetry_destination.go @@ -42,7 +42,8 @@ import ( // A group that did not arrive is not a cell without a backend. Reading the first // as the second would leave a deployment that converges green and exports // nothing, so a missing or unknown state, or the key the state requires being -// absent, refuses to start, naming what is missing. +// absent, refuses to start, naming what is missing. Only a local runtime may +// infer `absent` when the state is missing; module defaults never declare it. // // This file exists byte for byte in services/accounts/code and // services/auth-gateway/code: the two services are independent Go modules with @@ -124,17 +125,23 @@ func observabilityValue(key string) string { // configuredTelemetryDestination resolves the destination from the // `observability` group. -func configuredTelemetryDestination() (telemetryDestination, error) { - return resolveTelemetryDestination(observabilityValue) +func configuredTelemetryDestination(local bool) (telemetryDestination, error) { + return resolveTelemetryDestination(local, observabilityValue) } // resolveTelemetryDestination is the whole decision, over any reader of the // group's keys so a test can reach every outcome with nothing running. -func resolveTelemetryDestination(read func(key string) string) (telemetryDestination, error) { +func resolveTelemetryDestination(local bool, read func(key string) string) (telemetryDestination, error) { state := strings.TrimSpace(read(telemetryStateKey)) endpoint := strings.TrimSpace(read(telemetryEndpointKey)) reason := boundedLine(read(telemetryAbsentReasonKey), maxAbsentReasonLength) + if state == "" && local { + state = telemetryStateAbsent + if reason == "" { + reason = "A local run has no cell collector; traces go to the stdout tracer and metrics are not exported." + } + } if state == "" { return telemetryDestination{}, errors.New( "observability: " + telemetryStateKey + " is not set in the `observability` configuration group. " + diff --git a/module/services/accounts/code/telemetry_destination_test.go b/module/services/accounts/code/telemetry_destination_test.go index 4ac8a2b75..9c2010437 100644 --- a/module/services/accounts/code/telemetry_destination_test.go +++ b/module/services/accounts/code/telemetry_destination_test.go @@ -3,6 +3,7 @@ package main import ( "context" "net" + "os" "strings" "testing" "time" @@ -21,9 +22,56 @@ func group(values map[string]string) func(string) string { const collector = "http://otel-collector.otel-collector.svc.cluster.local:4317" +// Module defaults are also inherited by cells. They must not supply the state +// that only the platform can declare for a deployed workload. +func TestTelemetryDestinationShippedDefaultsCannotMaskMissingCellState(t *testing.T) { + contents, err := os.ReadFile("../../../configurations/local/observability.env") + require.NoError(t, err) + values := map[string]string{} + for _, line := range strings.Split(string(contents), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + require.True(t, ok) + values[key] = value + } + _, err = resolveTelemetryDestination(false, group(values)) + require.ErrorContains(t, err, "TELEMETRY_STATE is not set") + destination, err := resolveTelemetryDestination(true, group(values)) + require.NoError(t, err) + require.False(t, destination.Available()) + require.NotEmpty(t, destination.AbsentReason) +} + +func TestTelemetryDestinationMissingStateDefaultsOnlyInLocalRuntime(t *testing.T) { + for _, values := range []map[string]string{ + {}, + {"TELEMETRY_STATE": " "}, + {"TELEMETRY_ABSENT_REASON": "local defaults inherited by a cell"}, + {"OTEL_EXPORTER_OTLP_ENDPOINT": collector}, + } { + _, err := resolveTelemetryDestination(false, group(values)) + require.ErrorContains(t, err, "TELEMETRY_STATE is not set") + destination, err := resolveTelemetryDestination(true, group(values)) + require.NoError(t, err) + require.False(t, destination.Available()) + require.NotEmpty(t, destination.AbsentReason) + } + for _, values := range []map[string]string{ + {"TELEMETRY_STATE": "unknown"}, + {"TELEMETRY_STATE": "available"}, + {"TELEMETRY_STATE": "absent"}, + } { + _, err := resolveTelemetryDestination(true, group(values)) + require.Error(t, err, "an explicit state must be valid and complete even locally") + } +} + // The first outcome: the platform delivered a collector. func TestTelemetryDestinationAvailableExportsToTheEndpoint(t *testing.T) { - destination, err := resolveTelemetryDestination(group(map[string]string{ + destination, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, })) @@ -56,7 +104,7 @@ func TestTelemetryDestinationReadsTransportFromTheScheme(t *testing.T) { "https://10.0.0.7:4317": {"https://10.0.0.7:4317", "10.0.0.7:4317", false}, "http://otel-collector.ns.svc:4317/": {"http://otel-collector.ns.svc:4317", "otel-collector.ns.svc:4317", true}, } { - destination, err := resolveTelemetryDestination(group(map[string]string{ + destination, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": endpoint, })) @@ -70,7 +118,7 @@ func TestTelemetryDestinationReadsTransportFromTheScheme(t *testing.T) { // The second outcome: the cell has no collector, and says why. The process boots // and exports nothing. func TestTelemetryDestinationAbsentCarriesTheReason(t *testing.T) { - destination, err := resolveTelemetryDestination(group(map[string]string{ + destination, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "This cell's bucket has no ops cell, so no collector is rendered.", })) @@ -85,7 +133,7 @@ func TestTelemetryDestinationAbsentCarriesTheReason(t *testing.T) { // The reason is operator-written free text that reaches a log line. func TestTelemetryDestinationAbsentReasonIsOneBoundedLine(t *testing.T) { - destination, err := resolveTelemetryDestination(group(map[string]string{ + destination, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "no collector\n2026-01-01 FORGED entry\r\n" + strings.Repeat("x", 1000), })) @@ -170,7 +218,7 @@ func TestTelemetryDestinationRefusesEverythingElse(t *testing.T) { }, } { t.Run(name, func(t *testing.T) { - destination, err := resolveTelemetryDestination(group(tc.values)) + destination, err := resolveTelemetryDestination(false, group(tc.values)) require.Error(t, err) require.ErrorContains(t, err, tc.want) require.False(t, destination.Available(), "a refusal must never read as a usable destination") @@ -184,7 +232,7 @@ func TestTelemetryDestinationRefusesEverythingElse(t *testing.T) { // cell beside the reason the local profile left behind. That is not a // contradiction to refuse: the reason is ignored, and said so once. func TestTelemetryDestinationAvailableIgnoresALeftoverReason(t *testing.T) { - destination, err := resolveTelemetryDestination(group(map[string]string{ + destination, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, "TELEMETRY_ABSENT_REASON": "A local run has no cell collector.", @@ -210,7 +258,7 @@ func TestTelemetryDestinationAbsentIgnoresALeftoverEndpoint(t *testing.T) { "an unreadable endpoint": "http://[::1", } { t.Run(name, func(t *testing.T) { - destination, err := resolveTelemetryDestination(group(map[string]string{ + destination, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "This cell has no ops cell.", "OTEL_EXPORTER_OTLP_ENDPOINT": leftover, @@ -230,13 +278,13 @@ func TestTelemetryDestinationAbsentIgnoresALeftoverEndpoint(t *testing.T) { // A blank leftover is nothing left over. func TestTelemetryDestinationBlankLeftoversAreNotNoticed(t *testing.T) { - available, err := resolveTelemetryDestination(group(map[string]string{ + available, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, "TELEMETRY_ABSENT_REASON": " \n ", })) require.NoError(t, err) require.Empty(t, available.IgnoredNotice()) - absent, err := resolveTelemetryDestination(group(map[string]string{ + absent, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "no collector", "OTEL_EXPORTER_OTLP_ENDPOINT": " ", })) require.NoError(t, err) @@ -266,7 +314,7 @@ func TestTelemetryDestinationTheCellOverridesTheModuleDefaults(t *testing.T) { } t.Run("a cell with a collector overrides the local absent default", func(t *testing.T) { - destination, err := resolveTelemetryDestination(layered(moduleDefault, map[string]string{ + destination, err := resolveTelemetryDestination(false, layered(moduleDefault, map[string]string{ "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, })) @@ -278,7 +326,7 @@ func TestTelemetryDestinationTheCellOverridesTheModuleDefaults(t *testing.T) { }) t.Run("a cell with no collector, over a default that had an endpoint, is absent", func(t *testing.T) { - destination, err := resolveTelemetryDestination(layered( + destination, err := resolveTelemetryDestination(false, layered( map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, map[string]string{"TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "This cell has no ops cell."}, )) @@ -289,11 +337,11 @@ func TestTelemetryDestinationTheCellOverridesTheModuleDefaults(t *testing.T) { }) t.Run("a cell that overrides only the state still has to supply what it requires", func(t *testing.T) { - _, err := resolveTelemetryDestination(layered(moduleDefault, map[string]string{"TELEMETRY_STATE": "available"})) + _, err := resolveTelemetryDestination(false, layered(moduleDefault, map[string]string{"TELEMETRY_STATE": "available"})) require.ErrorContains(t, err, "OTEL_EXPORTER_OTLP_ENDPOINT is not set", "the module default's reason is not an endpoint") - _, err = resolveTelemetryDestination(layered( + _, err = resolveTelemetryDestination(false, layered( map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, map[string]string{"TELEMETRY_STATE": "absent"}, )) @@ -304,7 +352,7 @@ func TestTelemetryDestinationTheCellOverridesTheModuleDefaults(t *testing.T) { // A refusal quotes no part of an endpoint, which may carry credentials. func TestTelemetryDestinationRefusalDoesNotEchoCredentials(t *testing.T) { - _, err := resolveTelemetryDestination(group(map[string]string{ + _, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://user:hunter2@collector.example:4317", })) @@ -322,20 +370,24 @@ func TestConfiguredTelemetryDestinationReadsTheObservabilityGroup(t *testing.T) t.Setenv(prefix+key, "") } - _, err := configuredTelemetryDestination() + localDestination, err := configuredTelemetryDestination(true) + require.NoError(t, err) + require.False(t, localDestination.Available()) + require.NotEmpty(t, localDestination.AbsentReason) + _, err = configuredTelemetryDestination(false) require.ErrorContains(t, err, "TELEMETRY_STATE is not set") // Ambient variables a shell or a pod may carry are not the group. t.Setenv("TELEMETRY_STATE", "available") t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://ambient.example:4318") - _, err = configuredTelemetryDestination() + _, err = configuredTelemetryDestination(false) require.ErrorContains(t, err, "TELEMETRY_STATE is not set") t.Setenv("TELEMETRY_STATE", "") t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "") t.Setenv(prefix+"TELEMETRY_STATE", "available") t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", collector) - destination, err := configuredTelemetryDestination() + destination, err := configuredTelemetryDestination(false) require.NoError(t, err) require.Equal(t, "otel-collector.otel-collector.svc.cluster.local:4317", destination.Endpoint) require.True(t, destination.Insecure) @@ -343,7 +395,7 @@ func TestConfiguredTelemetryDestinationReadsTheObservabilityGroup(t *testing.T) t.Setenv(prefix+"TELEMETRY_STATE", "absent") t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", "") t.Setenv(prefix+"TELEMETRY_ABSENT_REASON", "a local run has no cell collector") - destination, err = configuredTelemetryDestination() + destination, err = configuredTelemetryDestination(false) require.NoError(t, err) require.False(t, destination.Available()) require.Equal(t, "a local run has no cell collector", destination.AbsentReason) @@ -358,7 +410,7 @@ func TestConfiguredTelemetryDestinationStateDecidesAcrossLayers(t *testing.T) { t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", collector) t.Setenv(prefix+"TELEMETRY_ABSENT_REASON", "A local run has no cell collector.") - destination, err := configuredTelemetryDestination() + destination, err := configuredTelemetryDestination(false) require.NoError(t, err) require.True(t, destination.Available()) require.Equal(t, collector, destination.URL) @@ -366,8 +418,8 @@ func TestConfiguredTelemetryDestinationStateDecidesAcrossLayers(t *testing.T) { } // Configuration that cannot work is refused before the process acquires -// anything, including in a local run: a group that did not arrive is not a cell -// without a collector anywhere. +// anything: a group that did not arrive is not a cell without a collector. +// Only a local runtime can infer an absent collector from the missing state. func TestStartupRefusesAnObservabilityGroupThatDidNotArrive(t *testing.T) { const prefix = "CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__" for _, key := range []string{"TELEMETRY_STATE", "OTEL_EXPORTER_OTLP_ENDPOINT", "TELEMETRY_ABSENT_REASON"} { @@ -376,9 +428,8 @@ func TestStartupRefusesAnObservabilityGroupThatDidNotArrive(t *testing.T) { // The key-custody requirement answers first outside a local run; satisfy it // so the observability requirement is what is under test. t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__VAULT__VAULT_KEY_CUSTODY", "seed-signing-key") - for _, local := range []bool{true, false} { - require.ErrorContains(t, requireStartupConfiguration(local), "TELEMETRY_STATE is not set", "local=%v", local) - } + require.NoError(t, requireStartupConfiguration(true)) + require.ErrorContains(t, requireStartupConfiguration(false), "TELEMETRY_STATE is not set") t.Setenv(prefix+"TELEMETRY_STATE", "absent") t.Setenv(prefix+"TELEMETRY_ABSENT_REASON", "a local run has no cell collector") diff --git a/module/services/accounts/code/telemetry_metrics_test.go b/module/services/accounts/code/telemetry_metrics_test.go index 7af83c684..4ae75bfd9 100644 --- a/module/services/accounts/code/telemetry_metrics_test.go +++ b/module/services/accounts/code/telemetry_metrics_test.go @@ -116,7 +116,7 @@ func TestTelemetryMetricsExportRuntimeAndUnsampledRED(t *testing.T) { // it really receives rather than a hand-built struct. func availableAt(t *testing.T, scheme, hostPort string) telemetryDestination { t.Helper() - destination, err := resolveTelemetryDestination(group(map[string]string{ + destination, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": scheme + "://" + hostPort, })) diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 34faee487..7429704bb 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -86,7 +86,7 @@ func doWork(ctx context.Context) (Clean, error) { // delivers in the `observability` configuration group — or the group says the // cell has none. Either answer was already required before anything was // acquired (requireStartupConfiguration); this reads it again to act on it. - telemetryDestination, err := configuredTelemetryDestination() + telemetryDestination, err := configuredTelemetryDestination(codefly.IsLocal()) if err != nil { return nil, err } @@ -2227,17 +2227,17 @@ func requireStartupConfiguration(isLocal bool) error { if err := requireKeyCustody(isLocal); err != nil { return err } - return requireTelemetryConfiguration() + return requireTelemetryConfiguration(isLocal) } // requireTelemetryConfiguration refuses to start unless the `observability` // group says where the cell's collector is, or that the cell has none. It only // checks: doWork reads the same answer again to act on it. A group that did not -// arrive is refused here, before the database or Vault is touched, because -// reading it as "no collector" would leave a cell exporting nothing while it -// converges green. -func requireTelemetryConfiguration() error { - _, err := configuredTelemetryDestination() +// arrive is refused outside the local runtime, before the database or Vault is +// touched: reading it as "no collector" would leave a cell exporting nothing +// while it converges green. +func requireTelemetryConfiguration(isLocal bool) error { + _, err := configuredTelemetryDestination(isLocal) return err } diff --git a/module/services/auth-gateway/code/main.go b/module/services/auth-gateway/code/main.go index 77e0471c7..27b01dddd 100644 --- a/module/services/auth-gateway/code/main.go +++ b/module/services/auth-gateway/code/main.go @@ -35,8 +35,8 @@ func main() { // The cell's collector is delivered in the `observability` configuration // group, or the group says the cell has none. A group that did not arrive is - // neither, so it is refused before anything else starts. - telemetryDestination, err := configuredTelemetryDestination() + // neither, so it is refused outside a local runtime before anything else starts. + telemetryDestination, err := configuredTelemetryDestination(codefly.IsLocal()) if err != nil { panic(err) } diff --git a/module/services/auth-gateway/code/telemetry_destination.go b/module/services/auth-gateway/code/telemetry_destination.go index c752d82fd..cecf41049 100644 --- a/module/services/auth-gateway/code/telemetry_destination.go +++ b/module/services/auth-gateway/code/telemetry_destination.go @@ -42,7 +42,8 @@ import ( // A group that did not arrive is not a cell without a backend. Reading the first // as the second would leave a deployment that converges green and exports // nothing, so a missing or unknown state, or the key the state requires being -// absent, refuses to start, naming what is missing. +// absent, refuses to start, naming what is missing. Only a local runtime may +// infer `absent` when the state is missing; module defaults never declare it. // // This file exists byte for byte in services/accounts/code and // services/auth-gateway/code: the two services are independent Go modules with @@ -124,17 +125,23 @@ func observabilityValue(key string) string { // configuredTelemetryDestination resolves the destination from the // `observability` group. -func configuredTelemetryDestination() (telemetryDestination, error) { - return resolveTelemetryDestination(observabilityValue) +func configuredTelemetryDestination(local bool) (telemetryDestination, error) { + return resolveTelemetryDestination(local, observabilityValue) } // resolveTelemetryDestination is the whole decision, over any reader of the // group's keys so a test can reach every outcome with nothing running. -func resolveTelemetryDestination(read func(key string) string) (telemetryDestination, error) { +func resolveTelemetryDestination(local bool, read func(key string) string) (telemetryDestination, error) { state := strings.TrimSpace(read(telemetryStateKey)) endpoint := strings.TrimSpace(read(telemetryEndpointKey)) reason := boundedLine(read(telemetryAbsentReasonKey), maxAbsentReasonLength) + if state == "" && local { + state = telemetryStateAbsent + if reason == "" { + reason = "A local run has no cell collector; traces go to the stdout tracer and metrics are not exported." + } + } if state == "" { return telemetryDestination{}, errors.New( "observability: " + telemetryStateKey + " is not set in the `observability` configuration group. " + diff --git a/module/services/auth-gateway/code/telemetry_destination_test.go b/module/services/auth-gateway/code/telemetry_destination_test.go index fe4f32e4a..cb6409b75 100644 --- a/module/services/auth-gateway/code/telemetry_destination_test.go +++ b/module/services/auth-gateway/code/telemetry_destination_test.go @@ -3,6 +3,7 @@ package main import ( "context" "net" + "os" "strings" "testing" "time" @@ -21,9 +22,56 @@ func group(values map[string]string) func(string) string { const collector = "http://otel-collector.otel-collector.svc.cluster.local:4317" +// Module defaults are also inherited by cells. They must not supply the state +// that only the platform can declare for a deployed workload. +func TestTelemetryDestinationShippedDefaultsCannotMaskMissingCellState(t *testing.T) { + contents, err := os.ReadFile("../../../configurations/local/observability.env") + require.NoError(t, err) + values := map[string]string{} + for _, line := range strings.Split(string(contents), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + require.True(t, ok) + values[key] = value + } + _, err = resolveTelemetryDestination(false, group(values)) + require.ErrorContains(t, err, "TELEMETRY_STATE is not set") + destination, err := resolveTelemetryDestination(true, group(values)) + require.NoError(t, err) + require.False(t, destination.Available()) + require.NotEmpty(t, destination.AbsentReason) +} + +func TestTelemetryDestinationMissingStateDefaultsOnlyInLocalRuntime(t *testing.T) { + for _, values := range []map[string]string{ + {}, + {"TELEMETRY_STATE": " "}, + {"TELEMETRY_ABSENT_REASON": "local defaults inherited by a cell"}, + {"OTEL_EXPORTER_OTLP_ENDPOINT": collector}, + } { + _, err := resolveTelemetryDestination(false, group(values)) + require.ErrorContains(t, err, "TELEMETRY_STATE is not set") + destination, err := resolveTelemetryDestination(true, group(values)) + require.NoError(t, err) + require.False(t, destination.Available()) + require.NotEmpty(t, destination.AbsentReason) + } + for _, values := range []map[string]string{ + {"TELEMETRY_STATE": "unknown"}, + {"TELEMETRY_STATE": "available"}, + {"TELEMETRY_STATE": "absent"}, + } { + _, err := resolveTelemetryDestination(true, group(values)) + require.Error(t, err, "an explicit state must be valid and complete even locally") + } +} + // The first outcome: the platform delivered a collector. func TestTelemetryDestinationAvailableExportsToTheEndpoint(t *testing.T) { - destination, err := resolveTelemetryDestination(group(map[string]string{ + destination, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, })) @@ -56,7 +104,7 @@ func TestTelemetryDestinationReadsTransportFromTheScheme(t *testing.T) { "https://10.0.0.7:4317": {"https://10.0.0.7:4317", "10.0.0.7:4317", false}, "http://otel-collector.ns.svc:4317/": {"http://otel-collector.ns.svc:4317", "otel-collector.ns.svc:4317", true}, } { - destination, err := resolveTelemetryDestination(group(map[string]string{ + destination, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": endpoint, })) @@ -70,7 +118,7 @@ func TestTelemetryDestinationReadsTransportFromTheScheme(t *testing.T) { // The second outcome: the cell has no collector, and says why. The process boots // and exports nothing. func TestTelemetryDestinationAbsentCarriesTheReason(t *testing.T) { - destination, err := resolveTelemetryDestination(group(map[string]string{ + destination, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "This cell's bucket has no ops cell, so no collector is rendered.", })) @@ -85,7 +133,7 @@ func TestTelemetryDestinationAbsentCarriesTheReason(t *testing.T) { // The reason is operator-written free text that reaches a log line. func TestTelemetryDestinationAbsentReasonIsOneBoundedLine(t *testing.T) { - destination, err := resolveTelemetryDestination(group(map[string]string{ + destination, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "no collector\n2026-01-01 FORGED entry\r\n" + strings.Repeat("x", 1000), })) @@ -170,7 +218,7 @@ func TestTelemetryDestinationRefusesEverythingElse(t *testing.T) { }, } { t.Run(name, func(t *testing.T) { - destination, err := resolveTelemetryDestination(group(tc.values)) + destination, err := resolveTelemetryDestination(false, group(tc.values)) require.Error(t, err) require.ErrorContains(t, err, tc.want) require.False(t, destination.Available(), "a refusal must never read as a usable destination") @@ -184,7 +232,7 @@ func TestTelemetryDestinationRefusesEverythingElse(t *testing.T) { // cell beside the reason the local profile left behind. That is not a // contradiction to refuse: the reason is ignored, and said so once. func TestTelemetryDestinationAvailableIgnoresALeftoverReason(t *testing.T) { - destination, err := resolveTelemetryDestination(group(map[string]string{ + destination, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, "TELEMETRY_ABSENT_REASON": "A local run has no cell collector.", @@ -210,7 +258,7 @@ func TestTelemetryDestinationAbsentIgnoresALeftoverEndpoint(t *testing.T) { "an unreadable endpoint": "http://[::1", } { t.Run(name, func(t *testing.T) { - destination, err := resolveTelemetryDestination(group(map[string]string{ + destination, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "This cell has no ops cell.", "OTEL_EXPORTER_OTLP_ENDPOINT": leftover, @@ -230,13 +278,13 @@ func TestTelemetryDestinationAbsentIgnoresALeftoverEndpoint(t *testing.T) { // A blank leftover is nothing left over. func TestTelemetryDestinationBlankLeftoversAreNotNoticed(t *testing.T) { - available, err := resolveTelemetryDestination(group(map[string]string{ + available, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, "TELEMETRY_ABSENT_REASON": " \n ", })) require.NoError(t, err) require.Empty(t, available.IgnoredNotice()) - absent, err := resolveTelemetryDestination(group(map[string]string{ + absent, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "no collector", "OTEL_EXPORTER_OTLP_ENDPOINT": " ", })) require.NoError(t, err) @@ -266,7 +314,7 @@ func TestTelemetryDestinationTheCellOverridesTheModuleDefaults(t *testing.T) { } t.Run("a cell with a collector overrides the local absent default", func(t *testing.T) { - destination, err := resolveTelemetryDestination(layered(moduleDefault, map[string]string{ + destination, err := resolveTelemetryDestination(false, layered(moduleDefault, map[string]string{ "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, })) @@ -278,7 +326,7 @@ func TestTelemetryDestinationTheCellOverridesTheModuleDefaults(t *testing.T) { }) t.Run("a cell with no collector, over a default that had an endpoint, is absent", func(t *testing.T) { - destination, err := resolveTelemetryDestination(layered( + destination, err := resolveTelemetryDestination(false, layered( map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, map[string]string{"TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "This cell has no ops cell."}, )) @@ -289,11 +337,11 @@ func TestTelemetryDestinationTheCellOverridesTheModuleDefaults(t *testing.T) { }) t.Run("a cell that overrides only the state still has to supply what it requires", func(t *testing.T) { - _, err := resolveTelemetryDestination(layered(moduleDefault, map[string]string{"TELEMETRY_STATE": "available"})) + _, err := resolveTelemetryDestination(false, layered(moduleDefault, map[string]string{"TELEMETRY_STATE": "available"})) require.ErrorContains(t, err, "OTEL_EXPORTER_OTLP_ENDPOINT is not set", "the module default's reason is not an endpoint") - _, err = resolveTelemetryDestination(layered( + _, err = resolveTelemetryDestination(false, layered( map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, map[string]string{"TELEMETRY_STATE": "absent"}, )) @@ -304,7 +352,7 @@ func TestTelemetryDestinationTheCellOverridesTheModuleDefaults(t *testing.T) { // A refusal quotes no part of an endpoint, which may carry credentials. func TestTelemetryDestinationRefusalDoesNotEchoCredentials(t *testing.T) { - _, err := resolveTelemetryDestination(group(map[string]string{ + _, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://user:hunter2@collector.example:4317", })) @@ -322,20 +370,24 @@ func TestConfiguredTelemetryDestinationReadsTheObservabilityGroup(t *testing.T) t.Setenv(prefix+key, "") } - _, err := configuredTelemetryDestination() + localDestination, err := configuredTelemetryDestination(true) + require.NoError(t, err) + require.False(t, localDestination.Available()) + require.NotEmpty(t, localDestination.AbsentReason) + _, err = configuredTelemetryDestination(false) require.ErrorContains(t, err, "TELEMETRY_STATE is not set") // Ambient variables a shell or a pod may carry are not the group. t.Setenv("TELEMETRY_STATE", "available") t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://ambient.example:4318") - _, err = configuredTelemetryDestination() + _, err = configuredTelemetryDestination(false) require.ErrorContains(t, err, "TELEMETRY_STATE is not set") t.Setenv("TELEMETRY_STATE", "") t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "") t.Setenv(prefix+"TELEMETRY_STATE", "available") t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", collector) - destination, err := configuredTelemetryDestination() + destination, err := configuredTelemetryDestination(false) require.NoError(t, err) require.Equal(t, "otel-collector.otel-collector.svc.cluster.local:4317", destination.Endpoint) require.True(t, destination.Insecure) @@ -343,7 +395,7 @@ func TestConfiguredTelemetryDestinationReadsTheObservabilityGroup(t *testing.T) t.Setenv(prefix+"TELEMETRY_STATE", "absent") t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", "") t.Setenv(prefix+"TELEMETRY_ABSENT_REASON", "a local run has no cell collector") - destination, err = configuredTelemetryDestination() + destination, err = configuredTelemetryDestination(false) require.NoError(t, err) require.False(t, destination.Available()) require.Equal(t, "a local run has no cell collector", destination.AbsentReason) @@ -358,7 +410,7 @@ func TestConfiguredTelemetryDestinationStateDecidesAcrossLayers(t *testing.T) { t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", collector) t.Setenv(prefix+"TELEMETRY_ABSENT_REASON", "A local run has no cell collector.") - destination, err := configuredTelemetryDestination() + destination, err := configuredTelemetryDestination(false) require.NoError(t, err) require.True(t, destination.Available()) require.Equal(t, collector, destination.URL) diff --git a/module/services/auth-gateway/code/telemetry_metrics_test.go b/module/services/auth-gateway/code/telemetry_metrics_test.go index 692e9eb8c..3f66755eb 100644 --- a/module/services/auth-gateway/code/telemetry_metrics_test.go +++ b/module/services/auth-gateway/code/telemetry_metrics_test.go @@ -139,7 +139,7 @@ func authFloat64SumValue(request *collectormetricsv1.ExportMetricsServiceRequest // it really receives rather than a hand-built struct. func availableAt(t *testing.T, scheme, hostPort string) telemetryDestination { t.Helper() - destination, err := resolveTelemetryDestination(group(map[string]string{ + destination, err := resolveTelemetryDestination(false, group(map[string]string{ "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": scheme + "://" + hostPort, })) From dbd33822c2c9d99b0f474909312097c83f156f7a Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:57:58 -0400 Subject: [PATCH 09/20] test(telemetry): select a key service backend before checking the observability requirement (Refs #1022) Main made the key service the first startup requirement outside a local run: `requireStartupConfiguration` now refuses an unset `KEY_SERVICE_BACKEND` before it reaches the observability group, so the test that proves a missing `TELEMETRY_STATE` is refused stopped reaching that refusal. It now names a backend and a key custody through the helpers the other startup tests use, so the observability requirement is what is under test again. Co-Authored-By: Claude Opus 5.5 --- .../services/accounts/code/telemetry_destination_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/module/services/accounts/code/telemetry_destination_test.go b/module/services/accounts/code/telemetry_destination_test.go index e06350de2..a3b1067c6 100644 --- a/module/services/accounts/code/telemetry_destination_test.go +++ b/module/services/accounts/code/telemetry_destination_test.go @@ -463,9 +463,11 @@ func TestStartupRefusesAnObservabilityGroupThatDidNotArrive(t *testing.T) { for _, key := range []string{"TELEMETRY_STATE", "OTEL_EXPORTER_OTLP_ENDPOINT", "TELEMETRY_ABSENT_REASON"} { t.Setenv(prefix+key, "") } - // The key-custody requirement answers first outside a local run; satisfy it - // so the observability requirement is what is under test. - t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__VAULT__VAULT_KEY_CUSTODY", "seed-signing-key") + // The key service's requirements answer first outside a local run; satisfy + // them so the observability requirement is what is under test. + clearVaultBinding(t) + keyServiceBackend(t, "vault") + keyCustody(t, "seed-signing-key") require.NoError(t, requireStartupConfiguration(t.Context(), true)) require.ErrorContains(t, requireStartupConfiguration(t.Context(), false), "TELEMETRY_STATE is not set") From 8053510c69237f89d8f160d85f431b6cf9df0f51 Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:57:58 -0400 Subject: [PATCH 10/20] test(accounts): declare the subject module's queues, namespaces and scope ceilings in the authority fixtures Core v0.15 requires every authority document to state the subject module's three declarations, `[]` when it owns none, so that a renderer which dropped one cannot read as "owns nothing". The two delivery fixtures that build an `AuthorityDocument` by hand left them nil, and eight database-backed tests in `pkg/infra` failed validation with "declares no queues" once the module took the bump. The fixtures now write all three as empty lists. A withdrawn generation states them empty too, which is what the document rule asks of it. This hunk is shared: it rides with the core bump, and the other feature's pull request carries the same lines, so whichever merges second rebases onto them. Co-Authored-By: Claude Opus 5.5 --- .../accounts/code/pkg/infra/approved_build_reconcile_test.go | 5 +++++ .../code/pkg/infra/solution_delivery_activation_test.go | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/module/services/accounts/code/pkg/infra/approved_build_reconcile_test.go b/module/services/accounts/code/pkg/infra/approved_build_reconcile_test.go index 6c9e2a3cb..c90341e9c 100644 --- a/module/services/accounts/code/pkg/infra/approved_build_reconcile_test.go +++ b/module/services/accounts/code/pkg/infra/approved_build_reconcile_test.go @@ -395,6 +395,11 @@ func (s *approvedBuildScenario) authority( Host: solutionhost.HostTarget{Coordinate: productionPathCoordinate, Component: "saas-host"}, OwnershipDomain: productionPathDomain, EnvelopeRevision: solutionhost.FixtureEnvelopeRevision, + // Core requires the subject module's three declarations on every + // generation, [] when it has none: a nil list reads as one a renderer dropped. + Queues: []string{}, + Namespaces: []string{}, + ScopeCeilings: []solutionhost.ScopeCeiling{}, } if removed { // A withdrawn generation names no principal, approves no build and is diff --git a/module/services/accounts/code/pkg/infra/solution_delivery_activation_test.go b/module/services/accounts/code/pkg/infra/solution_delivery_activation_test.go index 69028779f..59604c05d 100644 --- a/module/services/accounts/code/pkg/infra/solution_delivery_activation_test.go +++ b/module/services/accounts/code/pkg/infra/solution_delivery_activation_test.go @@ -225,6 +225,11 @@ func productionPathAuthority( Host: solutionhost.HostTarget{Coordinate: productionPathCoordinate, Component: "saas-host"}, OwnershipDomain: productionPathDomain, EnvelopeRevision: solutionhost.FixtureEnvelopeRevision, + // Core requires the subject module's three declarations on every + // generation, [] when it has none: a nil list reads as one a renderer dropped. + Queues: []string{}, + Namespaces: []string{}, + ScopeCeilings: []solutionhost.ScopeCeiling{}, } if !removed { document.ApprovedBuild = build From a55cc009d097a32fbd9211abdd3c9d1b47b5d0b5 Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:57:58 -0400 Subject: [PATCH 11/20] docs(deployment): name only the CLI support this change needs for promotion (Refs #1022) The topology page's promotion paragraph named the audit store's deploy-job driver beside Core's consumer-derived allow-list support. Only the second is this change's requirement. The shared sentences stand alone and the driver belongs to the audit store's pull request, which states it in a paragraph of its own, so the two pull requests no longer edit the same lines. Co-Authored-By: Claude Opus 5.5 --- module/DEPLOYMENT_TOPOLOGY.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/module/DEPLOYMENT_TOPOLOGY.md b/module/DEPLOYMENT_TOPOLOGY.md index 786d3de61..742d3b121 100644 --- a/module/DEPLOYMENT_TOPOLOGY.md +++ b/module/DEPLOYMENT_TOPOLOGY.md @@ -70,9 +70,7 @@ edges. Public frontend and marketing endpoints explicitly declare `exposure: public`, preserving their existing public addresses. Promotion requires a released CLI with Core's consumer-derived allow-list -support and the audit promotion driver from -[CLI PR 919](https://github.com/codefly-dev/cli/pull/919). Merge the CLI change, -release that compatible CLI, then promote this kit cutover. The module agent, +support. The module agent, accounts, auth-gateway, store migrator, and module tools require stable Core 0.14 or later to load these declarations and derive consumer access. The runtime SDK also loads the module when resolving local endpoints or secrets. Core 0.13 refuses From b771e74f6447faf1a847966b78d44a5825affb4d Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:11:01 -0400 Subject: [PATCH 12/20] docs(deployment): state the NetworkPolicy count the golden holds (Refs #1022) The topology page said the policy golden holds 18 resources, which was the count before main added the policy-log service's policies. The golden and the topology test hold 19, and the page now says so in both places it counts them. Co-Authored-By: Claude Opus 5.5 --- module/DEPLOYMENT_TOPOLOGY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/module/DEPLOYMENT_TOPOLOGY.md b/module/DEPLOYMENT_TOPOLOGY.md index 742d3b121..c658357cd 100644 --- a/module/DEPLOYMENT_TOPOLOGY.md +++ b/module/DEPLOYMENT_TOPOLOGY.md @@ -79,7 +79,7 @@ still loads the module with its older Core 0.7, but does not derive cross-module access: an internal endpoint with no authored list denies other modules. It is not a compatible promotion driver for this declaration model. -The topology-policy golden contains 18 `NetworkPolicy` resources: +The topology-policy golden contains 19 `NetworkPolicy` resources: - one namespace-wide ingress/egress default deny; - DNS and Istio control-plane egress for all injected workloads; @@ -310,7 +310,7 @@ exports, and missing descriptor-required accounts protocols. Parity tests build every artifact twice, compare all checked-in outputs, parse the generated files through Codefly's resource model, and strictly inspect all -18 NetworkPolicy golden documents. After the module generator creates the +19 NetworkPolicy golden documents. After the module generator creates the consumer-owned GitOps tree, render an environment with: ```sh From e4ce839f177502e568b69057c2bd99e2afdbc94e Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:50:26 -0400 Subject: [PATCH 13/20] refactor(accounts,auth-gateway): one telemetry variable, read by core (Refs #1022) The review of this change found the module reimplementing what core already does. wool/otel.Enable reads OTEL_EXPORTER_OTLP_ENDPOINT itself, tells a URL from a host:port, validates the URL, takes the transport from the scheme and refuses a portless one or one carrying credentials. The 326-line destination resolver in each service, its roughly 600-line test, the mirror test that held the two copies identical, and the fail-closed state machine around them (TELEMETRY_STATE, TELEMETRY_ABSENT_REASON) all collapse into the standard variable and one call. The whole contract is that one variable. The platform delivers it when the cell has a collector the workload may reach, and delivers nothing when it does not: - set: `wooltel.Enable` exports traces to it, and the metrics exporter, which reads the same variable, exports metrics to it; - unset: no OTLP exporter is built at all, and a process given no collector starts normally. There is no telemetry startup requirement any more, and the `observability` group no longer has a state or a reason to refuse on. Two behaviours of the dependencies decided the shape, each read from source: - `Enable` with no endpoint falls back to a stdout tracer, so it is only called when the variable is set or the run is local; a deployed process with none registers no tracer rather than printing every span to its log. - `otlpmetricgrpc.New` with nothing configured dials localhost:4317 over TLS, and it reads the variable only as an http:// or https:// URL, keeping that default on any other value without a word, where `Enable` takes such a value for a bare host:port. So the metrics call refuses an unset variable and any value that is not such a URL, instead of building an exporter pointed at the default. `IsLocal()` no longer decides whether to export. A pod deployed into an environment named `local` that is handed a collector exports to it; `IsLocal()` now only chooses wool's stdout tracer when no collector is named. An https:// endpoint is no longer refused at startup: core's URL path takes the transport from the scheme, which wool's tracer could not do before. `service.name` still comes from `OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES` or the service's own identity, and stays in a small `telemetry.go` per service with its tests. `configurations/local/observability.env` ships the one empty assignment the repository's every-declared-group-ships-a-default test needs, which is no endpoint. Nothing reads that key: a group value arrives as a `CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__` carrier, and the standard variable is what points a run at a collector. The docs, FEATURES.md, the local dogfood example and the package's breaking-changes record now describe the one variable. Classification: fix, at the place that owns the behaviour (core reads the variable); the module stops duplicating it. Not done: a NetworkPolicy egress rule for the collector in the generators (gitops.go and cataloggen). The collector's namespace and port are not part of the environment declaration they read, and the Vault precedent (`managed-services..egress-namespace`) is authored by hand in the workspace: the CLI's environment model has no such field and its contract parser refuses unknown keys. That needs the CLI to carry the field first; no rule is rendered from a guessed or typed value, and DEPLOYMENT_TOPOLOGY.md says so. Co-Authored-By: Claude Opus 5.5 --- .../skills/run-the-starter-locally/SKILL.md | 17 +- DOGFOODING.md | 4 +- LOCAL_DOGFOODING.md | 31 +- MODULE.md | 4 +- .../local-dogfood/observability.env.example | 28 +- module/DEPLOYMENT_TOPOLOGY.md | 15 +- module/FEATURES.md | 42 +- module/PRODUCT_ANALYTICS.md | 2 +- module/SIGNOZ_PROVISIONING.md | 4 +- module/configurations/local/observability.env | 49 +- module/module.package.codefly.yaml | 2 +- module/services/accounts/code/telemetry.go | 121 ++++ .../accounts/code/telemetry_destination.go | 326 --------- .../code/telemetry_destination_test.go | 625 ------------------ .../accounts/code/telemetry_metrics.go | 26 +- .../accounts/code/telemetry_metrics_test.go | 55 +- .../services/accounts/code/telemetry_test.go | 273 ++++++++ module/services/accounts/code/work.go | 56 +- module/services/auth-gateway/code/main.go | 36 +- .../services/auth-gateway/code/telemetry.go | 121 ++++ .../code/telemetry_destination.go | 326 --------- .../code/telemetry_destination_test.go | 603 ----------------- .../auth-gateway/code/telemetry_metrics.go | 26 +- .../code/telemetry_metrics_test.go | 55 +- .../auth-gateway/code/telemetry_test.go | 273 ++++++++ .../telemetry_destination_mirror_test.go | 40 -- 26 files changed, 1004 insertions(+), 2156 deletions(-) create mode 100644 module/services/accounts/code/telemetry.go delete mode 100644 module/services/accounts/code/telemetry_destination.go delete mode 100644 module/services/accounts/code/telemetry_destination_test.go create mode 100644 module/services/accounts/code/telemetry_test.go create mode 100644 module/services/auth-gateway/code/telemetry.go delete mode 100644 module/services/auth-gateway/code/telemetry_destination.go delete mode 100644 module/services/auth-gateway/code/telemetry_destination_test.go create mode 100644 module/services/auth-gateway/code/telemetry_test.go delete mode 100644 module/tools/telemetry_destination_mirror_test.go diff --git a/.claude/skills/run-the-starter-locally/SKILL.md b/.claude/skills/run-the-starter-locally/SKILL.md index c6e6a9a11..b8ea647db 100644 --- a/.claude/skills/run-the-starter-locally/SKILL.md +++ b/.claude/skills/run-the-starter-locally/SKILL.md @@ -18,15 +18,14 @@ codefly run service --fixture dev-admin - No TTY (CI, pipes, MCP) auto-enables `--headless`. - Docker must be running. `codefly doctor` checks prerequisites; `codefly clear` reaps stray processes and containers between runs. -- A local run has no cell collector, and the module ships none. The module's - `configurations/local/observability.env` declares neither `TELEMETRY_STATE` - nor `TELEMETRY_ABSENT_REASON`: those defaults also reach deployed cells. Only - the runtime the SDK identifies as `local` infers `absent` when the state is - missing, supplies the reason in code, logs it once, sends traces to wool's - stdout tracer and exports no metrics. Outside that runtime, a missing state - refuses startup. The `local-dogfood` profile is not that runtime: it must copy - its explicit state from `observability.env.example`, and with no collector it - exports no traces or metrics at all. +- A local run has no cell collector, and the module ships none. Traces and + metrics follow one standard variable, `OTEL_EXPORTER_OTLP_ENDPOINT`, in the + process environment: set it and the services export over OTLP, leave it unset + and they export nothing over OTLP. Only the runtime the SDK identifies as + `local` sends traces to wool's stdout tracer in that case; the `local-dogfood` + profile is not that runtime, so with no endpoint it exports no traces or + metrics at all. The module's `configurations/local/observability.env` names no + collector and nothing reads it. For a real external identity provider and the production-grade provider stack, use the `local-dogfood` environment and its configuration groups: diff --git a/DOGFOODING.md b/DOGFOODING.md index b4240ec6d..ad02ddae0 100644 --- a/DOGFOODING.md +++ b/DOGFOODING.md @@ -219,7 +219,7 @@ the same Codefly-managed graph. For each of `email` (Resend), `configurations/local-dogfood/.env.example` and `.secret.env.example` to their Git-ignored names and fill them in ([LOCAL_DOGFOODING.md](./LOCAL_DOGFOODING.md#configure-providers-through-codefly)). -Keep `observability.env.example` as it is for a `local-dogfood` run with no collector (`TELEMETRY_STATE=absent`). The plain `local` runtime infers `absent` when the state is missing, and supplies the reason itself; the module's local defaults declare neither. `local-dogfood` is not that runtime, so a run with no collector exports no traces or metrics. +Keep `observability.env.example` as it is for a `local-dogfood` run with no collector: with `OTEL_EXPORTER_OTLP_ENDPOINT` unset in the process environment nothing is exported over OTLP, and `local-dogfood` is not the plain `local` runtime, whose traces go to wool's stdout tracer, so a run with no collector exports no traces or metrics. Turnstile's published test keys drive the three abuse-protection cases: | Case | `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | `TURNSTILE_SECRET_KEY` | @@ -239,7 +239,7 @@ codefly run service --env local-dogfood - [ ] Grant analytics consent, navigate through onboarding, and verify bounded browser plus durable backend events in PostHog. - [ ] Withdraw analytics consent or log out. Browser capture stops immediately and identity resets. - [ ] Trigger controlled browser and backend errors. Sentry correlates release and environment without creating performance transactions. -- [ ] With `TELEMETRY_STATE=absent`, check that accounts and auth-gateway log the absent reason once at startup, then exercise login/onboarding; no traces or metrics are exported in `local-dogfood` (wool's stdout tracer is for the plain `local` runtime), so observe the logs on stdout. To see traces, point `OTEL_EXPORTER_OTLP_ENDPOINT` at a collector this machine can reach and set `TELEMETRY_STATE=available`. Remove `TELEMETRY_STATE` in `local-dogfood` and confirm both services refuse to start, naming it. In the plain `local` runtime, a missing state instead starts with an absent collector. +- [ ] With `OTEL_EXPORTER_OTLP_ENDPOINT` unset, check that accounts and auth-gateway log once at startup that OTLP export is disabled, then exercise login/onboarding; no traces or metrics are exported in `local-dogfood` (wool's stdout tracer is for the plain `local` runtime), so observe the logs on stdout. To see traces, set `OTEL_EXPORTER_OTLP_ENDPOINT` to a collector this machine can reach, for example `http://localhost:4317`, and confirm the startup line says export is enabled. Set it to a URL with no port and confirm the service refuses to start, naming the rule. - [ ] Switch Turnstile to the **fail** keys. Registration and waitlist submission fail without database writes. - [ ] Switch Turnstile to the **replay** keys. The first deterministic verification follows Cloudflare's fixture behavior; replay rejection leaves state unchanged. diff --git a/LOCAL_DOGFOODING.md b/LOCAL_DOGFOODING.md index 17ab22dd9..2e0c9ea01 100644 --- a/LOCAL_DOGFOODING.md +++ b/LOCAL_DOGFOODING.md @@ -11,7 +11,7 @@ Two Codefly inputs are intentionally independent: | Input | Authority | Purpose | |---|---|---| -| `--env local-dogfood` | `configurations/local-dogfood/*` | Select independently configured identity, billing, email, analytics, error, and abuse adapters, and the telemetry destination | +| `--env local-dogfood` | `configurations/local-dogfood/*` | Select independently configured identity, billing, email, analytics, error, and abuse adapters | | optional `--fixture ` | `module/fixtures/.yaml` | Seed a useful starting product state | A fixture never changes the identity provider. The ordinary `local` @@ -33,12 +33,11 @@ production/background fallback; a local port never belongs in product config. ## Configure providers through Codefly WorkOS is the identity adapter. The production-grade dogfood stack also -includes Stripe, Resend, PostHog, Sentry, the OpenTelemetry collector -destination, and Cloudflare Turnstile. Each one is a Codefly configuration group on this +includes Stripe, Resend, PostHog, Sentry, and Cloudflare Turnstile. Each one is a Codefly configuration group on this profile: copy the committed `configurations/local-dogfood/.env.example` (and `.secret.env.example`, where there is one) to the Git-ignored `.env` / `.secret.env`, and fill in the values. Every group is -optional except `observability` and `email` — see the notes below. Stripe setup belongs to +optional except `email` — see the notes below. Stripe setup belongs to the [`codefly-dev/provider-stripe`](https://github.com/codefly-dev/provider-stripe) plugin, which validates the account, observes the webhook and projects the `billing` group. @@ -50,23 +49,21 @@ plugin, which validates the account, observes the webhook and projects the | `email` | Resend | | `product-analytics` | PostHog | | `error-tracking` | Sentry | -| `observability` | the OpenTelemetry collector the cell provides, or none | +| `observability` | none: the collector is the standard `OTEL_EXPORTER_OTLP_ENDPOINT` variable, below | | `abuse-protection` | Cloudflare Turnstile | Validate the result with `codefly doctor workspace --env local-dogfood`. -`observability` is the one group this profile cannot skip. `accounts` and -`auth-gateway` declare it as a workspace-configuration-dependency, and on -`local-dogfood` the real `observability.env` is Git-ignored, so there is no -committed default to fall back on. Codefly skips a declared group whose files are -absent rather than failing, and both services refuse to start without an explicit -`TELEMETRY_STATE` — a group that did not arrive is not a cell without a collector, -and they will not guess. Copy `observability.env.example` as it is to run with -none: `TELEMETRY_STATE=absent` and its reason. Traces and metrics are then not -exported at all (logs still go to stdout): `local-dogfood` is not the `local` -runtime, and wool's stdout tracer belongs to that runtime alone. To export to a -collector this machine can reach, set `TELEMETRY_STATE=available` and -`OTEL_EXPORTER_OTLP_ENDPOINT` as the example says. +Traces and metrics follow one standard variable, `OTEL_EXPORTER_OTLP_ENDPOINT`, +in the environment the services run with: set it and `accounts` and `auth-gateway` +export over OTLP/gRPC, leave it unset and they export nothing over OTLP. That is +the default here, so `observability` needs no setup (logs still go to stdout): +`local-dogfood` is not the `local` runtime, and wool's stdout tracer belongs to +that runtime alone. To export to a collector this machine can reach, set the +variable to its address, for example `http://localhost:4317`. A value that names +no port, or carries credentials, stops the service at startup with the reason. +The group's own file, `observability.env`, exists only so the group does; nothing +reads the key in it. `email` is required because this profile is not the `local` environment, and accounts refuses to start there without an explicit `EMAIL_PROVIDER`: `resend` diff --git a/MODULE.md b/MODULE.md index e4ce70545..15a339b22 100644 --- a/MODULE.md +++ b/MODULE.md @@ -100,7 +100,7 @@ capability; it does not claim that every planned provider is released yet. | Product analytics | PostHog | The `product-analytics@1` browser/server APIs may capture registered events and perform consent-gated identify, alias, organization grouping, and privacy suppression. An event may carry a variant already evaluated by Unleash for experiment analysis. | Flag definition or evaluation, exception capture, and traces, metrics, or logs | | Session replay | PostHog | A browser-only recorder may start after separate replay consent and redaction policy resolve to allow. It has no server SDK or implicit analytics-consent fallback. | Flags, errors, and APM signals | | Error tracking | Sentry | Browser/server exception events, release/environment tags, source-map upload, and the error-issue workflow. Provisioning credentials remain build/provider-only. | Performance transactions, profiles, replay, logs, metrics, and feature flags | -| APM traces, metrics, and logs | SigNoz | Applications use OpenTelemetry SDKs and export traces and metrics over OTLP/gRPC to the cell's collector, whose address the platform delivers in the `observability` configuration group (or the group says the cell has none); logs go to stdout. SigNoz receives those signals as the OTLP backend. | Product analytics, replay, feature flags, and the Sentry error-issue contract | +| APM traces, metrics, and logs | SigNoz | Applications use OpenTelemetry SDKs and export traces and metrics over OTLP/gRPC to the cell's collector, whose address the platform delivers as the standard `OTEL_EXPORTER_OTLP_ENDPOINT` variable (or delivers nothing, and then nothing is exported); logs go to stdout. SigNoz receives those signals as the OTLP backend. | Product analytics, replay, feature flags, and the Sentry error-issue contract | Provider manifests are allowlists. `provider-posthog` may project only `product-analytics@1`; its browser initialization must disable flag remote @@ -112,7 +112,7 @@ Sentry trace sampling at zero and installs no Sentry tracing integration. dashboards and alerts but projects no application runtime configuration. In particular, dashboard provisioning is not an application telemetry contract: the application OTLP endpoint remains instrumentation configuration, delivered -as `OTEL_EXPORTER_OTLP_ENDPOINT` in the `observability` group when the cell has a +as the standard `OTEL_EXPORTER_OTLP_ENDPOINT` variable when the cell has a collector. The starter keeps the capability configurations independent. Selecting diff --git a/configurations/local-dogfood/observability.env.example b/configurations/local-dogfood/observability.env.example index 2276db415..4bc1ecebd 100644 --- a/configurations/local-dogfood/observability.env.example +++ b/configurations/local-dogfood/observability.env.example @@ -1,20 +1,20 @@ -# Copy to observability.env and fill it in. This module owns no collector: the -# cell's collector is where traces and metrics go, and the platform says where it is. +# Copy to observability.env. This module owns no collector: the cell's collector is +# where traces and metrics go, and the platform delivers its address as the standard +# OTEL_EXPORTER_OTLP_ENDPOINT variable in the process environment, or delivers nothing. +# +# A dogfood run on a laptop is not a cell, so by default it has none, and then +# neither traces nor metrics are exported: this profile is not the plain `local` +# runtime, and wool's stdout tracer belongs to that runtime alone. Logs still go to +# stdout. The empty assignment below is that default, and is here only so the group +# exists; nothing reads it, because a group value arrives as +# CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__OTEL_EXPORTER_OTLP_ENDPOINT. # -# A dogfood run on a laptop is not a cell, so by default it has none. Neither -# traces nor metrics are exported then: this profile is not the plain `local` -# runtime, and wool's stdout tracer belongs to that runtime alone. Logs still go -# to stdout. -TELEMETRY_STATE=absent -TELEMETRY_ABSENT_REASON=A local dogfood run has no cell collector; traces and metrics are not exported. - # To export to a collector this machine can reach (for example a cell's, through a -# port-forward), replace the two lines above with: +# port-forward), set the standard variable in the environment the services run with: # -# TELEMETRY_STATE=available # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 # # The endpoint is the collector's OTLP/gRPC address. http:// is plaintext, which -# only belongs on loopback or inside the mesh; https:// is TLS. Set the endpoint -# when the state is available and the reason when it is absent. The state decides: the -# other key is ignored, and the process logs that once. +# only belongs on loopback or inside the mesh; https:// is TLS. A value that names no +# port, or carries credentials, stops the service at startup with the reason. +OTEL_EXPORTER_OTLP_ENDPOINT= diff --git a/module/DEPLOYMENT_TOPOLOGY.md b/module/DEPLOYMENT_TOPOLOGY.md index c658357cd..70629c3eb 100644 --- a/module/DEPLOYMENT_TOPOLOGY.md +++ b/module/DEPLOYMENT_TOPOLOGY.md @@ -106,13 +106,14 @@ is tracked by `P1-CI-004`. The generated Codefly dependency declarations contain exact endpoint references. The module owns no collector: accounts and auth-gateway export traces -and metrics to the cell's, whose address the platform delivers in the -`observability` configuration group (`TELEMETRY_STATE`, with -`OTEL_EXPORTER_OTLP_ENDPOINT` when the cell has a collector and -`TELEMETRY_ABSENT_REASON` when it does not), so neither service needs public -egress to reach a telemetry backend and no product configuration owns a collector -port. The generated NetworkPolicy remains the hard endpoint/port enforcement -boundary for the services the module declares. +and metrics to the cell's, whose address the platform delivers as the standard +`OTEL_EXPORTER_OTLP_ENDPOINT` variable when the cell has a collector they may +reach, and delivers nothing when it does not. Neither service needs public egress +to reach a telemetry backend and no product configuration owns a collector port. +The generated NetworkPolicy remains the hard endpoint/port enforcement boundary +for the services the module declares, and it grants no egress to the cell's +collector: that collector's namespace and port are not part of the environment +declaration the generators read, so the module renders no rule for them. The AWS overlay replaces stateful services with managed dependencies. A pod-selector rule cannot authorize an RDS, ElastiCache, external Vault, or S3 diff --git a/module/FEATURES.md b/module/FEATURES.md index 2b8d68fda..bd2a15183 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -496,7 +496,7 @@ extraction contracts. | Structured logs| ✅ | `wool` everywhere; user/org/action context auto-attached | | Audit trail | ✅ | Separate from app logs; queryable | | Metrics | ✅ | Job-worker OTel instruments, durable queue projections, and Go runtime and request metrics pushed over OTLP to the cell's collector | -| Tracing | ✅ | Accounts and auth-gateway export OTLP to the cell's collector, whose address the platform delivers in the `observability` group, for the designated SigNoz backend | +| Tracing | ✅ | Accounts and auth-gateway export OTLP to the cell's collector, whose address the platform delivers as `OTEL_EXPORTER_OTLP_ENDPOINT`, for the designated SigNoz backend | | Error tracking | ✅ | Explicit fail-closed Sentry mode for server/browser errors; trace sampling is fixed at zero | | Dashboards | 🟡 | Versioned provider-neutral business dashboard pack; [SigNoz provisioning remains unsupported pending a pinned service qualification](SIGNOZ_PROVISIONING.md) | @@ -655,34 +655,28 @@ Environment variables consumed by the api: | `POSTHOG_API_HOST` | Separate PostHog management/deletion origin | | `ERROR_TRACKING_MODE` | Explicit `disabled` or `sentry`; rejects partial config | | `SENTRY_DSN` | Server Sentry DSN, required in Sentry mode | -| `TELEMETRY_STATE` | `observability` group, delivered by the platform: `available` or `absent`; missing state refuses startup except in the `local` runtime, where it means `absent` | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | `observability` group: the cell collector's OTLP/gRPC address, required when `available` and ignored when `absent`; `http://` is plaintext on the wire because the mesh supplies mTLS, `https://` is TLS | -| `TELEMETRY_ABSENT_REASON` | `observability` group: why the cell has no collector, required when `absent` and ignored when `available`; logged once at startup | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | The one telemetry variable, in the process environment: the cell collector's OTLP/gRPC address, delivered by the platform when the cell has a collector the workload may reach. Unset means nothing is exported over OTLP; `http://` is plaintext on the wire because the mesh supplies mTLS, `https://` is TLS | | `ABUSE_PROTECTION_MODE` | Explicit `disabled` or `turnstile` | | `TURNSTILE_SECRET_KEY` | Server-only Siteverify credential | | `TURNSTILE_ALLOWED_HOSTNAMES` | Exact accepted Turnstile response hostnames | | `CODEFLY__FIXTURE` | Loads fixture YAML (e.g. `dev-admin`); FE login picker too | -When `TELEMETRY_STATE` is `available`, the accounts service and the auth-gateway -export traces and unsampled request and Go runtime metrics over OTLP/gRPC to the -cell's collector at `OTEL_EXPORTER_OTLP_ENDPOINT`. The auth-gateway covers both its -HTTP gateway and gRPC ext_authz authorization service. When it is `absent`, they -export nothing over OTLP, log `TELEMETRY_ABSENT_REASON` once and boot. A local -runtime, meaning the Codefly environment named `local` and no other, then uses -wool's stdout tracer for traces; `local-dogfood` is not that runtime, so it exports -no traces either. The state decides: the group is layered, so a key the state does -not use can still arrive from a lower layer, and is ignored, with a startup warning -that names it once. The module's own profile declares neither `TELEMETRY_STATE` -nor `TELEMETRY_ABSENT_REASON`, because module defaults also reach deployed cells: a -healthy `available` cell would warn on every boot about a reason it never set, -and a cell that sent `absent` without a reason would pass with one written for a -laptop. Only a local runtime infers `absent` from a missing state, and it -supplies that state and its reason in code; elsewhere missing state refuses -startup, because a group that did not arrive is not a cell without a collector. -Unknown state, `available` without an endpoint, or explicit `absent` without a -reason also refuses startup, including locally. An `https://` -endpoint is refused at startup for now: wool's OTLP tracer dials plaintext only, -so it would be sent in the clear and reported as TLS. +When `OTEL_EXPORTER_OTLP_ENDPOINT` is set, the accounts service and the +auth-gateway export traces and unsampled request and Go runtime metrics over +OTLP/gRPC to the collector it names. The auth-gateway covers both its HTTP gateway +and gRPC ext_authz authorization service. That variable is the whole contract: +core's `wool/otel` reads it, tells a URL from a `host:port`, takes the transport +from the scheme, and refuses a URL with no port or with credentials, and the +metrics exporter reads it from the same environment. When it is unset the services +export nothing over OTLP and start normally: no state, reason or protocol is +delivered or read, and a process that is given no collector does not fail to +start. A local runtime, meaning the Codefly environment named `local` and no +other, then uses wool's stdout tracer for traces; `local-dogfood` is not that +runtime, so it exports no traces either. The endpoint decides, not where the +process runs: a collector delivered to a pod in an environment named `local` is +exported to. A value the metrics exporter cannot read (anything but an `http://` +or `https://` URL, which would otherwise make it dial `localhost:4317` in +silence) stops the service at startup. Metrics leave by OTLP push alone — decided 2026-10-06, because the cell's collector is the record for traces and metrics take the same path — so neither service serves a scrape endpoint. `service.name` comes from `OTEL_SERVICE_NAME` or diff --git a/module/PRODUCT_ANALYTICS.md b/module/PRODUCT_ANALYTICS.md index 20798f901..9c5a00f72 100644 --- a/module/PRODUCT_ANALYTICS.md +++ b/module/PRODUCT_ANALYTICS.md @@ -201,7 +201,7 @@ active organization. SigNoz is the single backend for application traces, metrics, and logs. The application surface remains standard OpenTelemetry/OTLP to the cell's collector, -whose address the platform delivers in the `observability` configuration group; an +whose address the platform delivers as `OTEL_EXPORTER_OTLP_ENDPOINT`; an optional SigNoz provider may provision dashboards and alerts but does not project an application SDK, endpoint, or credential. diff --git a/module/SIGNOZ_PROVISIONING.md b/module/SIGNOZ_PROVISIONING.md index e6fa41412..ad6c91bca 100644 --- a/module/SIGNOZ_PROVISIONING.md +++ b/module/SIGNOZ_PROVISIONING.md @@ -13,8 +13,8 @@ contract. This decision does not affect application telemetry. Applications export OTLP/gRPC to the cell's collector, at the address the platform delivers as -`OTEL_EXPORTER_OTLP_ENDPOINT` in the `observability` configuration group (or the -group says the cell has none); they do not hard-code it, and the module ships no +`OTEL_EXPORTER_OTLP_ENDPOINT` (or nothing, when the cell has no collector for them, +and then they export nothing); they do not hard-code it, and the module ships no collector of its own. Dashboard provisioning is not an application runtime capability. diff --git a/module/configurations/local/observability.env b/module/configurations/local/observability.env index be06056cb..857c7bd7d 100644 --- a/module/configurations/local/observability.env +++ b/module/configurations/local/observability.env @@ -1,33 +1,22 @@ -# Where accounts and auth-gateway send traces and metrics, or that they send none. -# The platform delivers exactly one of two answers, and a process that gets neither -# refuses to start outside a local runtime rather than guess: +# Where accounts and auth-gateway send traces and metrics: one standard variable, +# OTEL_EXPORTER_OTLP_ENDPOINT, the collector's OTLP/gRPC address, e.g. +# http://otel-collector.otel-collector.svc.cluster.local:4317 (http:// is plaintext +# because the mesh supplies mTLS; https:// is TLS). The platform delivers it, in the +# process environment, when the cell has a collector the workload may reach, and +# delivers nothing when it does not. Deliver it and the process exports over OTLP; +# deliver none and it exports nothing. Core's wool/otel and the OpenTelemetry metrics +# exporter read the variable from the process environment themselves, so there is no +# switch, no state and no reason to set. # -# TELEMETRY_STATE=available the cell has a collector. Also sets -# OTEL_EXPORTER_OTLP_ENDPOINT to its OTLP/gRPC address, -# e.g. http://otel-collector.otel-collector.svc.cluster.local:4317 -# (http:// is plaintext because the mesh supplies mTLS; -# https:// is TLS). -# TELEMETRY_STATE=absent the cell has none. Also sets TELEMETRY_ABSENT_REASON to -# say why; an absent cell that does not is refused. +# This profile names no collector, on purpose: module defaults also reach deployed +# cells, and a collector written here would be inherited by a cell that never set it. +# The one assignment below is empty, which is no endpoint, and is here only so the +# group exists: a composing workspace inherits a group from the key it assigns, and a +# group with no key is met at runtime-init as "no configuration found for +# observability". Nothing reads this key: a group value arrives as +# CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__OTEL_EXPORTER_OTLP_ENDPOINT, and +# the standard variable is what points a run at a collector. # -# The state decides: the key the state does not use is ignored, and the process logs -# that once. -# -# This profile declares neither the state nor the reason, on purpose. Module defaults -# also reach deployed cells, so either shipped here would be inherited by a cell that -# never set it: a healthy `available` cell would warn about a leftover reason on -# every boot, and a cell that sent `absent` without a reason would pass with a -# reason written for a laptop. -# -# A local run has no cell, so it has no collector, and says so itself: the services -# supply the `absent` state and its reason in code, only when the Codefly runtime is -# `local`. Traces then go to wool's stdout tracer and metrics are not exported. Logs -# always go to stdout. Any other environment, `local-dogfood` included, must deliver -# the state itself (see LOCAL_DOGFOODING.md). -# -# The one assignment below is empty, and is here only so the group exists: a -# composing workspace inherits a group from the key it assigns, and a group with no -# key is met at runtime-init as "no configuration found for observability". An empty -# endpoint is no endpoint, so it neither masks a cell's answer nor triggers the -# leftover-key warning. +# A local run with no endpoint exports nothing over OTLP: its traces go to wool's +# stdout tracer, and logs always go to stdout. OTEL_EXPORTER_OTLP_ENDPOINT= diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index cf689403f..eaf5eb3df 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -100,7 +100,7 @@ migrations: breaking-changes: - Consumers resolve the immutable package into a disposable projection instead of editing Starter-owned files. - "Audit event types are namespaced under `saas.*` (`auth.login` -> `saas.auth.login`). The wire value of `audit_events.event_type`, the `X-Webhook-Event` header, and webhook routing keys all change; migration 116 rewrites stored history and existing subscriptions in place, so a live subscriber keeps firing, but a consumer that re-creates a subscription from a hardcoded pre-namespace name, or emits one through `ModuleEmitAuditEvent`, is now rejected. No alias layer." - - "The `telemetry` service is removed: the module no longer ships an OTLP gateway, and the cell's collector takes its place. `accounts` and `auth-gateway` read where it is from the `observability` configuration group — `TELEMETRY_STATE` is `available` with `OTEL_EXPORTER_OTLP_ENDPOINT` (an `http://` endpoint is plaintext on the wire because the mesh supplies mTLS; `https://` is TLS), or `absent` with `TELEMETRY_ABSENT_REASON` — and refuse to start outside a local runtime when the group does not say which. Module defaults declare neither TELEMETRY_STATE nor TELEMETRY_ABSENT_REASON, because they also reach deployed cells; only the `local` runtime infers absent when the state is missing, and it supplies the state and its reason in code. `OBSERVABILITY_EXPORTER` and `OTEL_EXPORTER_OTLP_HEADERS` no longer mean anything, the `/metrics` scrape endpoint of both services is gone (metrics leave by OTLP push only), and `service.name` is no longer fixed in code: `OTEL_SERVICE_NAME` or `OTEL_RESOURCE_ATTRIBUTES` sets it, else the service's own Codefly identity. A consumer that depended on the `telemetry` service, or that overrides the `observability` group, must move to the keys above." + - "The `telemetry` service is removed: the module no longer ships an OTLP gateway, and the cell's collector takes its place. `accounts` and `auth-gateway` export traces and metrics over OTLP/gRPC when the standard `OTEL_EXPORTER_OTLP_ENDPOINT` variable is set in their process environment (an `http://` endpoint is plaintext on the wire because the mesh supplies mTLS; `https://` is TLS) and export nothing over OTLP when it is not; that one variable is the whole contract, so no state, reason or protocol key is delivered or read and a process given no collector starts normally. Core's `wool/otel` reads and validates the variable, and the services stop at startup on a value it refuses or that the metrics exporter cannot read. `OBSERVABILITY_EXPORTER` and `OTEL_EXPORTER_OTLP_HEADERS` no longer mean anything, the `/metrics` scrape endpoint of both services is gone (metrics leave by OTLP push only), and `service.name` is no longer fixed in code: `OTEL_SERVICE_NAME` or `OTEL_RESOURCE_ATTRIBUTES` sets it, else the service's own Codefly identity. A consumer that depended on the `telemetry` service, or that sets the collector through the `observability` configuration group, must set `OTEL_EXPORTER_OTLP_ENDPOINT` in the services' environment instead; the group's own key is no longer read." reserved-namespaces: - "@codefly/saas-" - "@codefly-dev/saas-" diff --git a/module/services/accounts/code/telemetry.go b/module/services/accounts/code/telemetry.go new file mode 100644 index 000000000..f25d0d03e --- /dev/null +++ b/module/services/accounts/code/telemetry.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "strings" + + "github.com/codefly-dev/core/wool" + wooltel "github.com/codefly-dev/core/wool/otel" + codefly "github.com/codefly-dev/sdk-go" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/sdk/resource" +) + +// otlpEndpointVariable is the whole telemetry contract: OpenTelemetry's own name +// for the collector's address. The platform delivers it when the cell has a +// collector this workload may reach, and delivers nothing when it does not. +// +// Deliver it and the process exports traces and metrics over OTLP/gRPC; deliver +// none and it exports nothing over OTLP. Nothing here reads a state, a reason or +// a protocol: core's wool/otel reads this variable itself, tells a URL from a +// host:port, takes the transport from the scheme, validates the URL and refuses a +// portless one, and the metrics exporter reads it from the same environment. +const otlpEndpointVariable = "OTEL_EXPORTER_OTLP_ENDPOINT" + +// otlpEndpointConfigured reports whether the process was given a collector. A +// whitespace-only value is none: the OpenTelemetry metrics exporter trims what it +// reads and treats an empty result as unset, and an exporter with nothing +// configured dials its own default, localhost:4317. +func otlpEndpointConfigured() bool { + return strings.TrimSpace(os.Getenv(otlpEndpointVariable)) != "" +} + +// enableTracing registers wool's tracer: OTLP/gRPC to the collector when the +// environment names one, with wool's own stdout tracer in its place on a local +// run that names none, and no tracer at all otherwise. A nil provider means +// nothing was registered. +// +// wool/otel owns the endpoint: it reads the variable, so a value it refuses (no +// scheme, no port, credentials in the URL) is returned as its error and nothing is +// registered. +func enableTracing(ctx context.Context, local bool) (*wooltel.Provider, error) { + configured := otlpEndpointConfigured() + if !configured && !local { + return nil, nil + } + res, err := currentTelemetryResource(ctx) + if err != nil { + return nil, fmt.Errorf("resolve telemetry resource: %w", err) + } + options := []wooltel.Option{wooltel.WithServiceName(telemetryServiceName(res))} + if !configured { + options = append(options, wooltel.WithStdout()) + } + return wooltel.Enable(options...) +} + +// requireMetricsEndpoint refuses what the metrics exporter cannot read. It reads +// the variable only as an http:// or https:// URL, and on anything else keeps its +// default and dials localhost:4317 without saying so, so a value of another shape +// (wool still accepts a bare host:port for traces) stops the process here. +func requireMetricsEndpoint() error { + if !otlpEndpointConfigured() { + return errors.New("no " + otlpEndpointVariable + " to export metrics to") + } + parsed, err := url.Parse(strings.TrimSpace(os.Getenv(otlpEndpointVariable))) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return errors.New(otlpEndpointVariable + " must be an http:// or https:// URL for metrics to be exported; the exporter ignores any other form and would fall back to its own default address") + } + return nil +} + +// telemetryResource is the one resource both signals carry. service.name is +// whatever the environment says — OTEL_SERVICE_NAME, or service.name in +// OTEL_RESOURCE_ATTRIBUTES — and otherwise the service identity the Codefly SDK +// holds. No name is typed here: a literal would override the environment, so the +// platform's identity could never reach the backend. +func telemetryResource(ctx context.Context, identity string) (*resource.Resource, error) { + options := []resource.Option{resource.WithService()} + if identity != "" { + options = append(options, resource.WithAttributes(attribute.String("service.name", identity))) + } + // Last, so what the environment declares wins over the identity above. + options = append(options, resource.WithFromEnv()) + return resource.New(ctx, options...) +} + +// currentTelemetryResource is telemetryResource for this process. +func currentTelemetryResource(ctx context.Context) (*resource.Resource, error) { + return telemetryResource(ctx, codeflyServiceName(codefly.Context())) +} + +// codeflyServiceName is the service identity the Codefly SDK holds: the +// `module/service` identity of the provider it built at Init, which wool stamps on +// every log line from this process. It is empty when the SDK has not been +// initialised. The SDK exposes no accessor of its own, and the environment +// carrier it reads this from is not product code's to read, so this asks the +// provider. +func codeflyServiceName(ctx context.Context) string { + provider, ok := ctx.Value(wool.ProviderKey).(*wool.Provider) + if !ok || provider == nil { + return "" + } + source := provider.Get(ctx).Source() + if source == nil { + return "" + } + // An identity with no module reads `/service`; with neither, `/`. + return strings.Trim(strings.TrimSpace(source.Unique), "/") +} + +// telemetryServiceName is the service.name the resource resolved to. +func telemetryServiceName(res *resource.Resource) string { + if value, ok := res.Set().Value(attribute.Key("service.name")); ok { + return value.AsString() + } + return "" +} diff --git a/module/services/accounts/code/telemetry_destination.go b/module/services/accounts/code/telemetry_destination.go deleted file mode 100644 index 650372193..000000000 --- a/module/services/accounts/code/telemetry_destination.go +++ /dev/null @@ -1,326 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "net" - "net/url" - "strings" - - "github.com/codefly-dev/core/wool" - wooltel "github.com/codefly-dev/core/wool/otel" - codefly "github.com/codefly-dev/sdk-go" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/sdk/resource" -) - -// Where this process sends traces and metrics — or that it sends none. -// -// The platform tells a workload through the `observability` configuration -// group, and the group always says one of two things: -// -// TELEMETRY_STATE=available with OTEL_EXPORTER_OTLP_ENDPOINT, the cell's -// collector (OTLP/gRPC), e.g. -// http://otel-collector.otel-collector.svc.cluster.local:4317 -// TELEMETRY_STATE=absent with TELEMETRY_ABSENT_REASON, why the cell has no -// collector -// -// `http://` is plaintext on the wire because the service mesh supplies mTLS -// between workloads; `https://` is TLS, which the metrics exporter dials but -// wool's tracer cannot yet (see errWoolCannotDialTLS). This module owns no -// collector: the cell does, and a workload that cannot find it does not -// substitute one. -// -// The state decides. The group is layered, so a key the state does not use can -// still arrive from a lower layer: it is ignored, not refused, and reported once -// through IgnoredNotice. What each state requires is unchanged: `available` needs -// an endpoint, `absent` needs a reason. The module's own profile ships neither -// the state nor the reason, so a cell that delivers `available` carries no -// leftover and a cell that delivers `absent` without a reason is refused. -// -// A group that did not arrive is not a cell without a backend. Reading the first -// as the second would leave a deployment that converges green and exports -// nothing, so a missing or unknown state, or the key the state requires being -// absent, refuses to start, naming what is missing. Only a local runtime may -// infer `absent` when the state is missing, and it supplies the reason itself -// (localAbsentReason): module defaults declare neither. -// -// This file exists byte for byte in services/accounts/code and -// services/auth-gateway/code: the two services are independent Go modules with -// no shared package, and module/tools/telemetry_destination_mirror_test.go holds -// the copies identical. Change one, copy it over the other. - -const ( - telemetryStateKey = "TELEMETRY_STATE" - telemetryEndpointKey = "OTEL_EXPORTER_OTLP_ENDPOINT" - telemetryAbsentReasonKey = "TELEMETRY_ABSENT_REASON" - - telemetryStateAvailable = "available" - telemetryStateAbsent = "absent" - - // maxAbsentReasonLength bounds the operator-written reason before it reaches - // a log line: an unbounded value from configuration would otherwise let - // whoever writes the group forge log entries around it. - maxAbsentReasonLength = 300 - - // localAbsentReason is the reason a local run gives for having no collector. - // It lives in code, and is used only when the process is local, because the - // module's configuration profile is inherited by deployed cells: a reason - // shipped there would reach a cell that sent `absent` without saying why. - localAbsentReason = "A local run has no cell collector; traces go to the stdout tracer and metrics are not exported." -) - -// telemetryDestination is the resolved answer. Exactly one of URL and -// AbsentReason is set. -type telemetryDestination struct { - // URL is the collector's origin as scheme://host:port, the port always - // explicit, which is the form otlpmetricgrpc.WithEndpointURL takes: the - // exporter reads its transport off the scheme. Empty when the cell has no - // collector. - URL string - // Endpoint is the same address as host:port. wool's tracer takes it in this - // form and has no option that reads a URL. Empty when the cell has no - // collector. - Endpoint string - // Insecure is true when the endpoint was http://, which the mesh protects. - // wool's tracer needs it spelled out; it is derived from the endpoint's - // scheme and never assumed. - Insecure bool - // AbsentReason says why the cell has no collector. Empty when URL is set. - AbsentReason string - // Ignored names the key the group carried that has no meaning for the state - // that decided: the absent reason under `available`, the endpoint under - // `absent`. Empty when the group carried nothing extra. - Ignored string -} - -// Available reports whether the cell has a collector to export to. -func (d telemetryDestination) Available() bool { - return d.Endpoint != "" -} - -// IgnoredNotice is the one line to log, once, when the group carried a key the -// state does not use. It is empty otherwise. It names the key and never its -// value: a leftover endpoint may carry credentials, and a leftover reason is -// free text. -func (d telemetryDestination) IgnoredNotice() string { - if d.Ignored == "" { - return "" - } - state := telemetryStateAbsent - if d.Available() { - state = telemetryStateAvailable - } - return fmt.Sprintf( - "observability: %s is set but %s is `%s`, which decides; %s is ignored (a leftover from a lower layer of the group)", - d.Ignored, telemetryStateKey, state, d.Ignored) -} - -// observabilityValue reads one key of the `observability` group through the -// SDK. It deliberately has no fallback to a bare process variable: an ambient -// OTEL_EXPORTER_OTLP_ENDPOINT in a shell or a pod is conventionally the -// OTLP/HTTP address, not the cell's gRPC collector, and reading it here would -// export to the wrong place without a word. -func observabilityValue(key string) string { - value, err := codefly.For(codefly.Context()).WorkspaceValue("observability", key) - if err != nil { - return "" - } - return value -} - -// configuredTelemetryDestination resolves the destination from the -// `observability` group. -func configuredTelemetryDestination(local bool) (telemetryDestination, error) { - return resolveTelemetryDestination(local, observabilityValue) -} - -// resolveTelemetryDestination is the whole decision, over any reader of the -// group's keys so a test can reach every outcome with nothing running. -func resolveTelemetryDestination(local bool, read func(key string) string) (telemetryDestination, error) { - state := strings.TrimSpace(read(telemetryStateKey)) - endpoint := strings.TrimSpace(read(telemetryEndpointKey)) - reason := boundedLine(read(telemetryAbsentReasonKey), maxAbsentReasonLength) - - if state == "" && local { - state = telemetryStateAbsent - if reason == "" { - reason = localAbsentReason - } - } - if state == "" { - return telemetryDestination{}, errors.New( - "observability: " + telemetryStateKey + " is not set in the `observability` configuration group. " + - "The platform delivers `" + telemetryStateAvailable + "` (with " + telemetryEndpointKey + ") or `" + - telemetryStateAbsent + "` (with " + telemetryAbsentReasonKey + "); a group that did not arrive is " + - "not a cell without a collector, so this process does not start without it") - } - if state != telemetryStateAvailable && state != telemetryStateAbsent { - return telemetryDestination{}, fmt.Errorf( - "observability: %s is %q in the `observability` configuration group; it must be `%s` or `%s`", - telemetryStateKey, state, telemetryStateAvailable, telemetryStateAbsent) - } - - // The state decides: the key that belongs to the other state is a leftover - // of a lower layer and is neither read nor refused. The endpoint under - // `absent` is not even parsed, so a stale one cannot stop a cell that says - // it has no collector. - if state == telemetryStateAbsent { - if reason == "" { - return telemetryDestination{}, fmt.Errorf( - "observability: %s is `%s` but %s is not set in the `observability` configuration group; "+ - "an absent collector must say why", - telemetryStateKey, telemetryStateAbsent, telemetryAbsentReasonKey) - } - destination := telemetryDestination{AbsentReason: reason} - if endpoint != "" { - destination.Ignored = telemetryEndpointKey - } - return destination, nil - } - - if endpoint == "" { - return telemetryDestination{}, fmt.Errorf( - "observability: %s is `%s` but %s is not set in the `observability` configuration group", - telemetryStateKey, telemetryStateAvailable, telemetryEndpointKey) - } - origin, hostPort, insecure, err := parseCollectorEndpoint(endpoint) - if err != nil { - return telemetryDestination{}, fmt.Errorf("observability: %s: %w", telemetryEndpointKey, err) - } - destination := telemetryDestination{URL: origin, Endpoint: hostPort, Insecure: insecure} - if reason != "" { - destination.Ignored = telemetryAbsentReasonKey - } - return destination, nil -} - -// parseCollectorEndpoint turns the delivered URL into the two forms the -// exporters take — the origin with an explicit port, and host:port — and reads -// the transport off its scheme. The URL is never echoed back: a malformed one -// may carry credentials. -func parseCollectorEndpoint(raw string) (origin, hostPort string, insecure bool, err error) { - parsed, parseErr := url.Parse(raw) - if parseErr != nil { - return "", "", false, errors.New("is not a URL; expected http://host:port or https://host:port") - } - switch parsed.Scheme { - case "http": - insecure = true - case "https": - default: - return "", "", false, fmt.Errorf("must start with http:// or https://, got scheme %q", parsed.Scheme) - } - if parsed.Hostname() == "" { - return "", "", false, errors.New("has no host") - } - if parsed.User != nil { - return "", "", false, errors.New("must not carry credentials") - } - // OTLP/gRPC addresses a collector, not a path: a path or a query is the - // OTLP/HTTP form (/v1/traces), which this exporter does not speak. - if (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { - return "", "", false, errors.New("must be the collector's origin only, with no path, query or fragment") - } - // The port is made explicit because gRPC would otherwise dial 443 whatever - // the scheme, and a plaintext http:// collector with no port is not on 443. - port := parsed.Port() - if port == "" { - port = "80" - if !insecure { - port = "443" - } - } - hostPort = net.JoinHostPort(parsed.Hostname(), port) - return parsed.Scheme + "://" + hostPort, hostPort, insecure, nil -} - -// boundedLine collapses a free-text value to one bounded line. -func boundedLine(value string, limit int) string { - line := strings.Join(strings.Fields(value), " ") - if runes := []rune(line); len(runes) > limit { - return string(runes[:limit]) + "…" - } - return line -} - -// telemetryResource is the one resource both signals carry. service.name is -// whatever the environment says — OTEL_SERVICE_NAME, or service.name in -// OTEL_RESOURCE_ATTRIBUTES — and otherwise the service identity the Codefly SDK -// holds. No name is typed here: a literal would override the environment, so the -// platform's identity could never reach the backend. -func telemetryResource(ctx context.Context, identity string) (*resource.Resource, error) { - options := []resource.Option{resource.WithService()} - if identity != "" { - options = append(options, resource.WithAttributes(attribute.String("service.name", identity))) - } - // Last, so what the environment declares wins over the identity above. - options = append(options, resource.WithFromEnv()) - return resource.New(ctx, options...) -} - -// currentTelemetryResource is telemetryResource for this process. -func currentTelemetryResource(ctx context.Context) (*resource.Resource, error) { - return telemetryResource(ctx, codeflyServiceName(codefly.Context())) -} - -// codeflyServiceName is the service identity the Codefly SDK holds: the -// `module/service` identity of the provider it built at Init, which wool stamps on -// every log line from this process. It is empty when the SDK has not been -// initialised. The SDK exposes no accessor of its own, and the environment -// carrier it reads this from is not product code's to read, so this asks the -// provider. -func codeflyServiceName(ctx context.Context) string { - provider, ok := ctx.Value(wool.ProviderKey).(*wool.Provider) - if !ok || provider == nil { - return "" - } - source := provider.Get(ctx).Source() - if source == nil { - return "" - } - // An identity with no module reads `/service`; with neither, `/`. - return strings.Trim(strings.TrimSpace(source.Unique), "/") -} - -// telemetryServiceName is the service.name the resource resolved to. -func telemetryServiceName(res *resource.Resource) string { - if value, ok := res.Set().Value(attribute.Key("service.name")); ok { - return value.AsString() - } - return "" -} - -// errWoolCannotDialTLS names the one transport the tracer cannot serve. -var errWoolCannotDialTLS = errors.New( - "the collector endpoint is https://, but wool's OTLP tracer cannot dial TLS: wool/otel.Enable " + - "always sets an insecure transport and offers no option to turn it off. Deliver an http:// " + - "endpoint (in-mesh, where the mesh supplies mTLS) until wool can dial TLS") - -// enableTracing registers wool's tracer for the destination: OTLP/gRPC to the -// cell's collector when there is one; wool's own stdout tracer when there is -// none and this is a local run; nothing otherwise. A nil provider means nothing -// was registered. -func enableTracing(ctx context.Context, destination telemetryDestination, local bool) (*wooltel.Provider, error) { - res, err := currentTelemetryResource(ctx) - if err != nil { - return nil, fmt.Errorf("resolve telemetry resource: %w", err) - } - options := []wooltel.Option{wooltel.WithServiceName(telemetryServiceName(res))} - switch { - case destination.Available(): - // wool dials plaintext unless told otherwise and cannot be told - // otherwise, so an https:// endpoint is refused rather than dialed in - // the clear and reported as TLS. - if !destination.Insecure { - return nil, errWoolCannotDialTLS - } - options = append(options, wooltel.WithEndpoint(destination.Endpoint), wooltel.WithInsecure()) - case local: - options = append(options, wooltel.WithStdout()) - default: - return nil, nil - } - return wooltel.Enable(options...) -} diff --git a/module/services/accounts/code/telemetry_destination_test.go b/module/services/accounts/code/telemetry_destination_test.go deleted file mode 100644 index a3b1067c6..000000000 --- a/module/services/accounts/code/telemetry_destination_test.go +++ /dev/null @@ -1,625 +0,0 @@ -package main - -import ( - "context" - "net" - "os" - "strings" - "testing" - "time" - - "github.com/codefly-dev/core/wool" - "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel" - collectortracev1 "go.opentelemetry.io/proto/otlp/collector/trace/v1" - "google.golang.org/grpc" -) - -// group is a reader over a fixed set of `observability` keys. -func group(values map[string]string) func(string) string { - return func(key string) string { return values[key] } -} - -const collector = "http://otel-collector.otel-collector.svc.cluster.local:4317" - -// shippedObservabilityProfile is what the module's own `observability` profile -// contributes to the group, read from the file it ships. -func shippedObservabilityProfile(t *testing.T) map[string]string { - t.Helper() - contents, err := os.ReadFile("../../../configurations/local/observability.env") - require.NoError(t, err) - values := map[string]string{} - for _, line := range strings.Split(string(contents), "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - key, value, ok := strings.Cut(line, "=") - require.True(t, ok) - values[key] = value - } - return values -} - -// Module defaults are also inherited by cells, so the profile ships neither the -// state nor the reason: a deployed workload must get both from the platform, and -// a local run gets them from code (localAbsentReason), only when it is local. -func TestTelemetryDestinationShippedProfileDeclaresNothingACellCouldInherit(t *testing.T) { - shipped := shippedObservabilityProfile(t) - require.NotContains(t, shipped, "TELEMETRY_STATE", "every deployed cell inherits the profile") - require.NotContains(t, shipped, "TELEMETRY_ABSENT_REASON", "every deployed cell inherits the profile") - require.Empty(t, shipped["OTEL_EXPORTER_OTLP_ENDPOINT"], "the profile keeps the group in existence and names no collector") - - _, err := resolveTelemetryDestination(false, group(shipped)) - require.ErrorContains(t, err, "TELEMETRY_STATE is not set") - destination, err := resolveTelemetryDestination(true, group(shipped)) - require.NoError(t, err) - require.False(t, destination.Available()) - require.Equal(t, localAbsentReason, destination.AbsentReason, "a local run supplies its own reason in code") - require.Empty(t, destination.IgnoredNotice()) -} - -// Layered over the shipped profile, a healthy cell leaves nothing to warn about, -// and a cell that says `absent` without a reason is refused rather than passed -// with the local-run reason. -func TestTelemetryDestinationCellAnswersAreNotMaskedByTheShippedProfile(t *testing.T) { - shipped := shippedObservabilityProfile(t) - - available, err := resolveTelemetryDestination(false, layered(shipped, map[string]string{ - "TELEMETRY_STATE": "available", - "OTEL_EXPORTER_OTLP_ENDPOINT": collector, - })) - require.NoError(t, err) - require.True(t, available.Available()) - require.Empty(t, available.Ignored, "an available cell must not log a leftover-key warning on every boot") - require.Empty(t, available.IgnoredNotice()) - - _, err = resolveTelemetryDestination(false, layered(shipped, map[string]string{"TELEMETRY_STATE": "absent"})) - require.ErrorContains(t, err, "TELEMETRY_ABSENT_REASON is not set") - - // The same answer from a local run is refused too: the local reason fills in - // a missing state, never an explicit `absent` that did not say why. - _, err = resolveTelemetryDestination(true, layered(shipped, map[string]string{"TELEMETRY_STATE": "absent"})) - require.ErrorContains(t, err, "TELEMETRY_ABSENT_REASON is not set") -} - -func TestTelemetryDestinationMissingStateDefaultsOnlyInLocalRuntime(t *testing.T) { - for _, values := range []map[string]string{ - {}, - {"TELEMETRY_STATE": " "}, - {"TELEMETRY_ABSENT_REASON": "local defaults inherited by a cell"}, - {"OTEL_EXPORTER_OTLP_ENDPOINT": collector}, - } { - _, err := resolveTelemetryDestination(false, group(values)) - require.ErrorContains(t, err, "TELEMETRY_STATE is not set") - destination, err := resolveTelemetryDestination(true, group(values)) - require.NoError(t, err) - require.False(t, destination.Available()) - require.NotEmpty(t, destination.AbsentReason) - } - for _, values := range []map[string]string{ - {"TELEMETRY_STATE": "unknown"}, - {"TELEMETRY_STATE": "available"}, - {"TELEMETRY_STATE": "absent"}, - } { - _, err := resolveTelemetryDestination(true, group(values)) - require.Error(t, err, "an explicit state must be valid and complete even locally") - } -} - -// The first outcome: the platform delivered a collector. -func TestTelemetryDestinationAvailableExportsToTheEndpoint(t *testing.T) { - destination, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "available", - "OTEL_EXPORTER_OTLP_ENDPOINT": collector, - })) - require.NoError(t, err) - require.True(t, destination.Available()) - require.Equal(t, collector, destination.URL, "the metrics exporter reads its transport off this URL") - require.Equal(t, "otel-collector.otel-collector.svc.cluster.local:4317", destination.Endpoint) - require.True(t, destination.Insecure, "http:// is plaintext on the wire; the mesh supplies mTLS") - require.Empty(t, destination.AbsentReason) - require.Empty(t, destination.Ignored) - require.Empty(t, destination.IgnoredNotice()) -} - -// The transport comes from the endpoint's scheme, never from a constant, and -// the URL handed to the metrics exporter always carries an explicit port: gRPC -// would otherwise dial 443 whatever the scheme. -func TestTelemetryDestinationReadsTransportFromTheScheme(t *testing.T) { - for endpoint, want := range map[string]struct { - url string - hostPort string - insecure bool - }{ - "http://collector.example:4317": {"http://collector.example:4317", "collector.example:4317", true}, - "https://collector.example:4317": {"https://collector.example:4317", "collector.example:4317", false}, - "https://collector.example": {"https://collector.example:443", "collector.example:443", false}, - "http://collector.example": {"http://collector.example:80", "collector.example:80", true}, - "http://collector.example:4317/": {"http://collector.example:4317", "collector.example:4317", true}, - " http://collector.example:4317 ": {"http://collector.example:4317", "collector.example:4317", true}, - "http://[2001:db8::1]:4317": {"http://[2001:db8::1]:4317", "[2001:db8::1]:4317", true}, - "https://10.0.0.7:4317": {"https://10.0.0.7:4317", "10.0.0.7:4317", false}, - "http://otel-collector.ns.svc:4317/": {"http://otel-collector.ns.svc:4317", "otel-collector.ns.svc:4317", true}, - } { - destination, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "available", - "OTEL_EXPORTER_OTLP_ENDPOINT": endpoint, - })) - require.NoError(t, err, endpoint) - require.Equal(t, want.url, destination.URL, endpoint) - require.Equal(t, want.hostPort, destination.Endpoint, endpoint) - require.Equal(t, want.insecure, destination.Insecure, endpoint) - } -} - -// The second outcome: the cell has no collector, and says why. The process boots -// and exports nothing. -func TestTelemetryDestinationAbsentCarriesTheReason(t *testing.T) { - destination, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "absent", - "TELEMETRY_ABSENT_REASON": "This cell's bucket has no ops cell, so no collector is rendered.", - })) - require.NoError(t, err) - require.False(t, destination.Available()) - require.Empty(t, destination.URL) - require.Empty(t, destination.Endpoint) - require.Equal(t, "This cell's bucket has no ops cell, so no collector is rendered.", destination.AbsentReason) - require.Empty(t, destination.Ignored) - require.Empty(t, destination.IgnoredNotice()) -} - -// The reason is operator-written free text that reaches a log line. -func TestTelemetryDestinationAbsentReasonIsOneBoundedLine(t *testing.T) { - destination, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "absent", - "TELEMETRY_ABSENT_REASON": "no collector\n2026-01-01 FORGED entry\r\n" + strings.Repeat("x", 1000), - })) - require.NoError(t, err) - require.NotContains(t, destination.AbsentReason, "\n") - require.NotContains(t, destination.AbsentReason, "\r") - require.LessOrEqual(t, len([]rune(destination.AbsentReason)), maxAbsentReasonLength+1) -} - -// The third outcome: anything else refuses to start, naming what is wrong. -// Configuration that did not arrive is never read as "no collector". A key the -// state does not use is not a refusal — it is ignored, below — but a key the -// state requires, missing, always is. -func TestTelemetryDestinationRefusesEverythingElse(t *testing.T) { - for name, tc := range map[string]struct { - values map[string]string - want string - }{ - "state missing": { - values: map[string]string{}, - want: "TELEMETRY_STATE is not set", - }, - "state missing though an ambient endpoint is present": { - values: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": collector}, - want: "TELEMETRY_STATE is not set", - }, - "state missing though a reason is present": { - values: map[string]string{"TELEMETRY_ABSENT_REASON": "no collector"}, - want: "TELEMETRY_STATE is not set", - }, - "state blank": { - values: map[string]string{"TELEMETRY_STATE": " "}, - want: "TELEMETRY_STATE is not set", - }, - "state unknown": { - values: map[string]string{"TELEMETRY_STATE": "disabled", "TELEMETRY_ABSENT_REASON": "no collector"}, - want: `TELEMETRY_STATE is "disabled"`, - }, - "state is not matched case-insensitively": { - values: map[string]string{"TELEMETRY_STATE": "Available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, - want: `TELEMETRY_STATE is "Available"`, - }, - "available without an endpoint": { - values: map[string]string{"TELEMETRY_STATE": "available"}, - want: "OTEL_EXPORTER_OTLP_ENDPOINT is not set", - }, - "available with only a reason": { - values: map[string]string{"TELEMETRY_STATE": "available", "TELEMETRY_ABSENT_REASON": "no collector"}, - want: "OTEL_EXPORTER_OTLP_ENDPOINT is not set", - }, - "absent without a reason": { - values: map[string]string{"TELEMETRY_STATE": "absent"}, - want: "TELEMETRY_ABSENT_REASON is not set", - }, - "absent with a blank reason": { - values: map[string]string{"TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": " \n "}, - want: "TELEMETRY_ABSENT_REASON is not set", - }, - "absent with only an endpoint": { - values: map[string]string{"TELEMETRY_STATE": "absent", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, - want: "TELEMETRY_ABSENT_REASON is not set", - }, - "endpoint without a scheme": { - values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "otel-collector.ns.svc:4317"}, - want: "must start with http:// or https://", - }, - "endpoint with another scheme": { - values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "grpc://otel-collector.ns.svc:4317"}, - want: `got scheme "grpc"`, - }, - "endpoint without a host": { - values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://:4317"}, - want: "has no host", - }, - "endpoint naming an OTLP/HTTP path": { - values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector.example:4318/v1/traces"}, - want: "origin only", - }, - "endpoint with a query": { - values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector.example:4317?x=1"}, - want: "origin only", - }, - } { - t.Run(name, func(t *testing.T) { - destination, err := resolveTelemetryDestination(false, group(tc.values)) - require.Error(t, err) - require.ErrorContains(t, err, tc.want) - require.False(t, destination.Available(), "a refusal must never read as a usable destination") - require.Empty(t, destination.AbsentReason, "a refusal must never read as an absent collector") - }) - } -} - -// The state decides. The group is layered, so a deployed producer can still see -// `available` and an endpoint from the cell beside a reason a lower layer (a -// consuming workspace's own profile, say) left behind. That is not a -// contradiction to refuse: the reason is ignored, and said so once. -func TestTelemetryDestinationAvailableIgnoresALeftoverReason(t *testing.T) { - destination, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "available", - "OTEL_EXPORTER_OTLP_ENDPOINT": collector, - "TELEMETRY_ABSENT_REASON": "A local run has no cell collector.", - })) - require.NoError(t, err) - require.True(t, destination.Available()) - require.Equal(t, collector, destination.URL) - require.Empty(t, destination.AbsentReason, "a leftover reason must not turn an available collector into an absent one") - require.Equal(t, "TELEMETRY_ABSENT_REASON", destination.Ignored) - notice := destination.IgnoredNotice() - require.Contains(t, notice, "TELEMETRY_ABSENT_REASON is set but TELEMETRY_STATE is `available`") - require.NotContains(t, notice, "A local run", "the notice names the key, never its free-text value") -} - -// ... and the other way round: a cell that says it has no collector is not -// refused for an endpoint a lower layer left behind, and that endpoint is not -// even parsed. -func TestTelemetryDestinationAbsentIgnoresALeftoverEndpoint(t *testing.T) { - for name, leftover := range map[string]string{ - "a usable endpoint": collector, - "a malformed endpoint": "otel-collector.ns.svc:4317", - "an endpoint with a user": "http://user:hunter2@collector.example:4317", - "an unreadable endpoint": "http://[::1", - } { - t.Run(name, func(t *testing.T) { - destination, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "absent", - "TELEMETRY_ABSENT_REASON": "This cell has no ops cell.", - "OTEL_EXPORTER_OTLP_ENDPOINT": leftover, - })) - require.NoError(t, err) - require.False(t, destination.Available(), "a leftover endpoint must not turn an absent collector into an available one") - require.Empty(t, destination.URL) - require.Empty(t, destination.Endpoint) - require.Equal(t, "This cell has no ops cell.", destination.AbsentReason) - require.Equal(t, "OTEL_EXPORTER_OTLP_ENDPOINT", destination.Ignored) - notice := destination.IgnoredNotice() - require.Contains(t, notice, "OTEL_EXPORTER_OTLP_ENDPOINT is set but TELEMETRY_STATE is `absent`") - require.NotContains(t, notice, "hunter2", "the notice names the key, never its value") - }) - } -} - -// A blank leftover is nothing left over. -func TestTelemetryDestinationBlankLeftoversAreNotNoticed(t *testing.T) { - available, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, "TELEMETRY_ABSENT_REASON": " \n ", - })) - require.NoError(t, err) - require.Empty(t, available.IgnoredNotice()) - - absent, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "no collector", "OTEL_EXPORTER_OTLP_ENDPOINT": " ", - })) - require.NoError(t, err) - require.Empty(t, absent.IgnoredNotice()) -} - -// layered is what the platform hands a workload: each layer overrides the one -// below it key by key, so a key a higher layer does not set survives from the -// lower one. -func layered(layers ...map[string]string) func(string) string { - merged := map[string]string{} - for _, layer := range layers { - for key, value := range layer { - merged[key] = value - } - } - return group(merged) -} - -// The case this exists for: a lower layer of the group says `absent` with a -// reason, and a deployed cell overrides the state and the endpoint but not the -// reason. The cell's answer wins, whichever way it points. -func TestTelemetryDestinationTheCellOverridesALowerLayer(t *testing.T) { - moduleDefault := map[string]string{ - "TELEMETRY_STATE": "absent", - "TELEMETRY_ABSENT_REASON": "A local run has no cell collector.", - } - - t.Run("a cell with a collector overrides a lower layer's absent default", func(t *testing.T) { - destination, err := resolveTelemetryDestination(false, layered(moduleDefault, map[string]string{ - "TELEMETRY_STATE": "available", - "OTEL_EXPORTER_OTLP_ENDPOINT": collector, - })) - require.NoError(t, err) - require.True(t, destination.Available()) - require.Equal(t, collector, destination.URL) - require.Empty(t, destination.AbsentReason) - require.Equal(t, "TELEMETRY_ABSENT_REASON", destination.Ignored) - }) - - t.Run("a cell with no collector, over a default that had an endpoint, is absent", func(t *testing.T) { - destination, err := resolveTelemetryDestination(false, layered( - map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, - map[string]string{"TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "This cell has no ops cell."}, - )) - require.NoError(t, err) - require.False(t, destination.Available()) - require.Equal(t, "This cell has no ops cell.", destination.AbsentReason) - require.Equal(t, "OTEL_EXPORTER_OTLP_ENDPOINT", destination.Ignored) - }) - - t.Run("a cell that overrides only the state still has to supply what it requires", func(t *testing.T) { - _, err := resolveTelemetryDestination(false, layered(moduleDefault, map[string]string{"TELEMETRY_STATE": "available"})) - require.ErrorContains(t, err, "OTEL_EXPORTER_OTLP_ENDPOINT is not set", - "the module default's reason is not an endpoint") - - _, err = resolveTelemetryDestination(false, layered( - map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, - map[string]string{"TELEMETRY_STATE": "absent"}, - )) - require.ErrorContains(t, err, "TELEMETRY_ABSENT_REASON is not set", - "the module default's endpoint is not a reason") - }) -} - -// A refusal quotes no part of an endpoint, which may carry credentials. -func TestTelemetryDestinationRefusalDoesNotEchoCredentials(t *testing.T) { - _, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "available", - "OTEL_EXPORTER_OTLP_ENDPOINT": "http://user:hunter2@collector.example:4317", - })) - require.ErrorContains(t, err, "must not carry credentials") - require.NotContains(t, err.Error(), "hunter2") -} - -// The group is read through the SDK's workspace-configuration lookup, by the -// names the platform delivers them under. A bare process variable of the same -// name is not the cell's answer. -func TestConfiguredTelemetryDestinationReadsTheObservabilityGroup(t *testing.T) { - const prefix = "CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__" - for _, key := range []string{"TELEMETRY_STATE", "OTEL_EXPORTER_OTLP_ENDPOINT", "TELEMETRY_ABSENT_REASON"} { - t.Setenv(key, "") - t.Setenv(prefix+key, "") - } - - localDestination, err := configuredTelemetryDestination(true) - require.NoError(t, err) - require.False(t, localDestination.Available()) - require.NotEmpty(t, localDestination.AbsentReason) - _, err = configuredTelemetryDestination(false) - require.ErrorContains(t, err, "TELEMETRY_STATE is not set") - - // Ambient variables a shell or a pod may carry are not the group. - t.Setenv("TELEMETRY_STATE", "available") - t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://ambient.example:4318") - _, err = configuredTelemetryDestination(false) - require.ErrorContains(t, err, "TELEMETRY_STATE is not set") - t.Setenv("TELEMETRY_STATE", "") - t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "") - - t.Setenv(prefix+"TELEMETRY_STATE", "available") - t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", collector) - destination, err := configuredTelemetryDestination(false) - require.NoError(t, err) - require.Equal(t, "otel-collector.otel-collector.svc.cluster.local:4317", destination.Endpoint) - require.True(t, destination.Insecure) - - t.Setenv(prefix+"TELEMETRY_STATE", "absent") - t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", "") - t.Setenv(prefix+"TELEMETRY_ABSENT_REASON", "a local run has no cell collector") - destination, err = configuredTelemetryDestination(false) - require.NoError(t, err) - require.False(t, destination.Available()) - require.Equal(t, "a local run has no cell collector", destination.AbsentReason) -} - -// Through the SDK's lookup, with the keys exactly as a deployed cell delivers -// them after the layers merge: the cell's state and endpoint, and a reason a -// lower layer left behind. -func TestConfiguredTelemetryDestinationStateDecidesAcrossLayers(t *testing.T) { - const prefix = "CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__" - t.Setenv(prefix+"TELEMETRY_STATE", "available") - t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", collector) - t.Setenv(prefix+"TELEMETRY_ABSENT_REASON", "A local run has no cell collector.") - - destination, err := configuredTelemetryDestination(false) - require.NoError(t, err) - require.True(t, destination.Available()) - require.Equal(t, collector, destination.URL) - require.NotEmpty(t, destination.IgnoredNotice()) -} - -// Configuration that cannot work is refused before the process acquires -// anything: a group that did not arrive is not a cell without a collector. -// Only a local runtime can infer an absent collector from the missing state. -func TestStartupRefusesAnObservabilityGroupThatDidNotArrive(t *testing.T) { - const prefix = "CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__" - for _, key := range []string{"TELEMETRY_STATE", "OTEL_EXPORTER_OTLP_ENDPOINT", "TELEMETRY_ABSENT_REASON"} { - t.Setenv(prefix+key, "") - } - // The key service's requirements answer first outside a local run; satisfy - // them so the observability requirement is what is under test. - clearVaultBinding(t) - keyServiceBackend(t, "vault") - keyCustody(t, "seed-signing-key") - require.NoError(t, requireStartupConfiguration(t.Context(), true)) - require.ErrorContains(t, requireStartupConfiguration(t.Context(), false), "TELEMETRY_STATE is not set") - - t.Setenv(prefix+"TELEMETRY_STATE", "absent") - t.Setenv(prefix+"TELEMETRY_ABSENT_REASON", "a local run has no cell collector") - require.NoError(t, requireStartupConfiguration(t.Context(), true)) - require.NoError(t, requireStartupConfiguration(t.Context(), false)) -} - -// service.name is never a literal typed here: the environment wins, then the -// service's own Codefly identity. -func TestTelemetryResourceServiceName(t *testing.T) { - resolve := func(t *testing.T, identity string) string { - t.Helper() - res, err := telemetryResource(t.Context(), identity) - require.NoError(t, err) - return telemetryServiceName(res) - } - - t.Run("falls back to the Codefly service identity", func(t *testing.T) { - t.Setenv("OTEL_SERVICE_NAME", "") - t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") - require.Equal(t, "saas-starter/accounts", resolve(t, "saas-starter/accounts")) - }) - t.Run("OTEL_SERVICE_NAME wins over the identity", func(t *testing.T) { - t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") - t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") - require.Equal(t, "named-by-the-platform", resolve(t, "saas-starter/accounts")) - }) - t.Run("OTEL_RESOURCE_ATTRIBUTES can set it", func(t *testing.T) { - t.Setenv("OTEL_SERVICE_NAME", "") - t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.name=attributed-by-the-platform,deployment.environment=test") - require.Equal(t, "attributed-by-the-platform", resolve(t, "saas-starter/accounts")) - }) - t.Run("without either, the SDK default rather than a literal", func(t *testing.T) { - t.Setenv("OTEL_SERVICE_NAME", "") - t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") - require.True(t, strings.HasPrefix(resolve(t, ""), "unknown_service"), resolve(t, "")) - }) -} - -// The identity is the one the SDK built its provider with, the same one wool -// stamps on this process's log lines — read from the provider, not from the -// runtime's environment carrier. -func TestCodeflyServiceNameIsTheSDKProviderIdentity(t *testing.T) { - carrying := func(unique string) context.Context { - return wool.New(t.Context(), &wool.Resource{Kind: "service", Unique: unique}).Inject(t.Context()) - } - require.Equal(t, "saas-starter/accounts", codeflyServiceName(carrying("saas-starter/accounts"))) - require.Equal(t, "accounts", codeflyServiceName(carrying("/accounts")), "an identity with no module") - require.Empty(t, codeflyServiceName(carrying("/")), "an identity with neither") - require.Empty(t, codeflyServiceName(t.Context()), "no provider: the SDK was not initialised") -} - -// wool's tracer speaks plaintext only. An https:// endpoint is refused rather -// than dialed in the clear and reported as TLS. -func TestEnableTracingRefusesATransportWoolCannotDial(t *testing.T) { - provider, err := enableTracing(t.Context(), - telemetryDestination{Endpoint: "collector.example:4317", Insecure: false}, false) - require.ErrorIs(t, err, errWoolCannotDialTLS) - require.Nil(t, provider) -} - -func TestEnableTracingRegistersOnlyWhatTheDestinationCalls(t *testing.T) { - restore := func(t *testing.T) { - t.Helper() - previous := otel.GetTracerProvider() - t.Cleanup(func() { - wool.RegisterTelemetry(nil) - otel.SetTracerProvider(previous) - }) - } - absent := telemetryDestination{AbsentReason: "a local run has no cell collector"} - - t.Run("absent outside a local run registers no tracer", func(t *testing.T) { - restore(t) - provider, err := enableTracing(t.Context(), absent, false) - require.NoError(t, err) - require.Nil(t, provider) - require.False(t, wool.TelemetryEnabled()) - }) - t.Run("absent in a local run uses wool's stdout tracer", func(t *testing.T) { - restore(t) - provider, err := enableTracing(t.Context(), absent, true) - require.NoError(t, err) - require.NotNil(t, provider) - require.True(t, wool.TelemetryEnabled()) - shutdown(t, provider) - }) - t.Run("available over http exports spans to the collector, named by the environment", func(t *testing.T) { - restore(t) - t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") - t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") - endpoint, capture := startTraceCapture(t) - provider, err := enableTracing(t.Context(), telemetryDestination{Endpoint: endpoint, Insecure: true}, false) - require.NoError(t, err) - require.NotNil(t, provider) - require.True(t, wool.TelemetryEnabled()) - - _, span := otel.Tracer("test").Start(t.Context(), "operation") - span.End() - shutdown(t, provider) // flushes the batch - - select { - case request := <-capture.requests: - var name string - for _, resourceSpans := range request.GetResourceSpans() { - for _, attribute := range resourceSpans.GetResource().GetAttributes() { - if attribute.GetKey() == "service.name" { - name = attribute.GetValue().GetStringValue() - } - } - } - require.Equal(t, "named-by-the-platform", name) - case <-time.After(5 * time.Second): - t.Fatal("timed out waiting for OTLP spans") - } - }) -} - -type traceCapture struct { - collectortracev1.UnimplementedTraceServiceServer - requests chan *collectortracev1.ExportTraceServiceRequest -} - -func (c *traceCapture) Export( - _ context.Context, - request *collectortracev1.ExportTraceServiceRequest, -) (*collectortracev1.ExportTraceServiceResponse, error) { - c.requests <- request - return &collectortracev1.ExportTraceServiceResponse{}, nil -} - -// startTraceCapture serves an in-process OTLP/gRPC trace receiver on loopback. -func startTraceCapture(t *testing.T) (string, *traceCapture) { - t.Helper() - listener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - capture := &traceCapture{requests: make(chan *collectortracev1.ExportTraceServiceRequest, 4)} - server := grpc.NewServer() - collectortracev1.RegisterTraceServiceServer(server, capture) - go func() { _ = server.Serve(listener) }() - t.Cleanup(func() { - server.Stop() - _ = listener.Close() - }) - return listener.Addr().String(), capture -} - -func shutdown(t *testing.T, provider interface{ Shutdown(context.Context) error }) { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - require.NoError(t, provider.Shutdown(ctx)) -} diff --git a/module/services/accounts/code/telemetry_metrics.go b/module/services/accounts/code/telemetry_metrics.go index b7ce54122..38099ddc8 100644 --- a/module/services/accounts/code/telemetry_metrics.go +++ b/module/services/accounts/code/telemetry_metrics.go @@ -2,7 +2,6 @@ package main import ( "context" - "errors" runtimemetrics "runtime/metrics" "time" @@ -14,17 +13,21 @@ import ( ) // otelMetrics owns the process's MeterProvider. Metrics leave by one path only: -// an OTLP push to the cell's collector, which is the record for traces and -// metrics alike. There is no scrape endpoint to mount, expose or exempt. +// an OTLP push to the collector OTEL_EXPORTER_OTLP_ENDPOINT names, which is the +// record for traces and metrics alike. There is no scrape endpoint to mount, +// expose or exempt. type otelMetrics struct { provider *metric.MeterProvider } -func enableOTELMetrics(ctx context.Context, destination telemetryDestination) (*otelMetrics, error) { - if destination.URL == "" { - // WithEndpointURL("") would fall back to the exporter's own default, - // localhost:4317: a process that exports to nowhere and reports success. - return nil, errors.New("telemetry: no collector URL to export metrics to") +// enableOTELMetrics starts the process's OTLP metrics push to the collector the +// environment names. It is only for a process that was given one: with no +// OTEL_EXPORTER_OTLP_ENDPOINT it refuses rather than build an exporter, because +// an otlpmetricgrpc exporter with nothing configured dials its own default, +// localhost:4317 over TLS, and a process that exports to nowhere reports success. +func enableOTELMetrics(ctx context.Context) (*otelMetrics, error) { + if err := requireMetricsEndpoint(); err != nil { + return nil, err } // The resource comes before the exporter, so a failure here leaves nothing // to shut down: an exporter holds a client connection that only Shutdown @@ -33,9 +36,10 @@ func enableOTELMetrics(ctx context.Context, destination telemetryDestination) (* if err != nil { return nil, err } - // The exporter reads the transport off the URL's scheme itself: http:// is - // plaintext on the wire, because the mesh supplies mTLS, and https:// is TLS. - exporter, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithEndpointURL(destination.URL)) + // The exporter reads the collector from OTEL_EXPORTER_OTLP_ENDPOINT itself and + // takes the transport off the URL's scheme: http:// is plaintext on the wire, + // because the mesh supplies mTLS, and https:// is TLS. + exporter, err := otlpmetricgrpc.New(ctx) if err != nil { return nil, err } diff --git a/module/services/accounts/code/telemetry_metrics_test.go b/module/services/accounts/code/telemetry_metrics_test.go index 4ae75bfd9..b4ecb1275 100644 --- a/module/services/accounts/code/telemetry_metrics_test.go +++ b/module/services/accounts/code/telemetry_metrics_test.go @@ -71,7 +71,8 @@ func TestTelemetryMetricsExportRuntimeAndUnsampledRED(t *testing.T) { ) otel.SetTracerProvider(tracerProvider) - metrics, err := enableOTELMetrics(t.Context(), availableAt(t, "http", endpoint)) + collectorAt(t, "http", endpoint) + metrics, err := enableOTELMetrics(t.Context()) require.NoError(t, err) require.NotNil(t, metrics) t.Cleanup(func() { @@ -111,26 +112,14 @@ func TestTelemetryMetricsExportRuntimeAndUnsampledRED(t *testing.T) { require.Empty(t, spanExporter.GetSpans()) } -// availableAt resolves the destination the way the process does, from the -// endpoint the platform would deliver, so these tests hand the exporter the URL -// it really receives rather than a hand-built struct. -func availableAt(t *testing.T, scheme, hostPort string) telemetryDestination { - t.Helper() - destination, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "available", - "OTEL_EXPORTER_OTLP_ENDPOINT": scheme + "://" + hostPort, - })) - require.NoError(t, err) - return destination -} - // The exporter reads its transport off the URL's scheme, so an https:// URL is // TLS and cannot talk to a plaintext receiver. A constant WithInsecure() would // have let it. func TestEnableOTELMetricsDerivesTransportFromTheURLScheme(t *testing.T) { endpoint, capture := startMetricCapture(t) previousMeterProvider := otel.GetMeterProvider() - metrics, err := enableOTELMetrics(t.Context(), availableAt(t, "https", endpoint)) + collectorAt(t, "https", endpoint) + metrics, err := enableOTELMetrics(t.Context()) require.NoError(t, err) require.NotNil(t, metrics) t.Cleanup(func() { @@ -150,12 +139,33 @@ func TestEnableOTELMetricsDerivesTransportFromTheURLScheme(t *testing.T) { } } -// A destination with no collector URL is refused. WithEndpointURL("") would -// otherwise fall back to the exporter's own default, localhost:4317. -func TestEnableOTELMetricsRefusesADestinationWithoutAURL(t *testing.T) { - metrics, err := enableOTELMetrics(t.Context(), telemetryDestination{AbsentReason: "no collector"}) - require.ErrorContains(t, err, "no collector URL") - require.Nil(t, metrics) +// A process with no collector configured builds no exporter: an +// otlpmetricgrpc exporter with nothing configured dials its own default, +// localhost:4317, so the call refuses instead. +func TestEnableOTELMetricsRefusesWithoutAnEndpoint(t *testing.T) { + for name, value := range map[string]string{"unset": "", "blank": " "} { + t.Run(name, func(t *testing.T) { + isolateOTLPEnvironment(t) + t.Setenv(otlpEndpointVariable, value) + metrics, err := enableOTELMetrics(t.Context()) + require.ErrorContains(t, err, "no "+otlpEndpointVariable) + require.Nil(t, metrics) + }) + } +} + +// The exporter reads the variable only as an http:// or https:// URL. Any other +// form keeps its default, localhost:4317, without a word, so it is refused here, +// and the refusal does not echo the value, which may carry credentials. +func TestEnableOTELMetricsRefusesAnEndpointTheExporterCannotRead(t *testing.T) { + for _, value := range []string{"collector.example:4317", "ftp://collector.example:4317", "://bad", "secret-user:secret-pass@collector.example:4317"} { + isolateOTLPEnvironment(t) + t.Setenv(otlpEndpointVariable, value) + metrics, err := enableOTELMetrics(t.Context()) + require.ErrorContains(t, err, "http:// or https://", value) + require.NotContains(t, err.Error(), "secret-pass") + require.Nil(t, metrics) + } } // A resource that cannot be built fails the whole call, and the call builds it @@ -165,7 +175,8 @@ func TestEnableOTELMetricsFailsWhenTheResourceCannotBeBuilt(t *testing.T) { t.Setenv("OTEL_SERVICE_NAME", "") t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "an-attribute-with-no-value") endpoint, _ := startMetricCapture(t) - metrics, err := enableOTELMetrics(t.Context(), availableAt(t, "http", endpoint)) + collectorAt(t, "http", endpoint) + metrics, err := enableOTELMetrics(t.Context()) require.Error(t, err) require.Nil(t, metrics) } diff --git a/module/services/accounts/code/telemetry_test.go b/module/services/accounts/code/telemetry_test.go new file mode 100644 index 000000000..bca3591df --- /dev/null +++ b/module/services/accounts/code/telemetry_test.go @@ -0,0 +1,273 @@ +package main + +import ( + "context" + "net" + "os" + "strings" + "testing" + "time" + + "github.com/codefly-dev/core/wool" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + collectortracev1 "go.opentelemetry.io/proto/otlp/collector/trace/v1" + "google.golang.org/grpc" +) + +// isolateOTLPEnvironment clears every OTLP variable the exporters read, so what a +// test sets is the whole configuration and nothing leaks in from the machine +// running it. +func isolateOTLPEnvironment(t *testing.T) { + t.Helper() + for _, name := range []string{ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_CERTIFICATE", + "OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE", + "OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE", + "OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE", + "OTEL_EXPORTER_OTLP_CLIENT_KEY", + "OTEL_EXPORTER_OTLP_INSECURE", + "OTEL_EXPORTER_OTLP_HEADERS", + } { + t.Setenv(name, "") + } +} + +// collectorAt is the platform's whole delivery: the one variable, set to the +// collector's address. +func collectorAt(t *testing.T, scheme, hostPort string) { + t.Helper() + isolateOTLPEnvironment(t) + t.Setenv(otlpEndpointVariable, scheme+"://"+hostPort) +} + +// shippedObservabilityProfile is what the module's own `observability` profile +// contributes to the group, read from the file it ships. +func shippedObservabilityProfile(t *testing.T) map[string]string { + t.Helper() + contents, err := os.ReadFile("../../../configurations/local/observability.env") + require.NoError(t, err) + values := map[string]string{} + for _, line := range strings.Split(string(contents), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + require.True(t, ok) + values[key] = value + } + return values +} + +// Module defaults also reach deployed cells, so the profile names no collector: +// the group exists (a composing workspace inherits a group from the key it +// assigns), and the one variable it assigns is empty, which is no endpoint. +func TestShippedObservabilityProfileNamesNoCollector(t *testing.T) { + require.Equal(t, map[string]string{otlpEndpointVariable: ""}, shippedObservabilityProfile(t)) +} + +func TestOTLPEndpointConfigured(t *testing.T) { + isolateOTLPEnvironment(t) + require.False(t, otlpEndpointConfigured(), "unset") + t.Setenv(otlpEndpointVariable, " ") + require.False(t, otlpEndpointConfigured(), "a blank value is no endpoint: the metrics exporter trims it to unset") + t.Setenv(otlpEndpointVariable, "http://otel-collector.otel-collector.svc.cluster.local:4317") + require.True(t, otlpEndpointConfigured()) +} + +// service.name is never a literal typed here: the environment wins, then the +// service's own Codefly identity. +func TestTelemetryResourceServiceName(t *testing.T) { + resolve := func(t *testing.T, identity string) string { + t.Helper() + res, err := telemetryResource(t.Context(), identity) + require.NoError(t, err) + return telemetryServiceName(res) + } + + t.Run("falls back to the Codefly service identity", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + require.Equal(t, "saas-starter/accounts", resolve(t, "saas-starter/accounts")) + }) + t.Run("OTEL_SERVICE_NAME wins over the identity", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + require.Equal(t, "named-by-the-platform", resolve(t, "saas-starter/accounts")) + }) + t.Run("OTEL_RESOURCE_ATTRIBUTES can set it", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.name=attributed-by-the-platform,deployment.environment=test") + require.Equal(t, "attributed-by-the-platform", resolve(t, "saas-starter/accounts")) + }) + t.Run("without either, the SDK default rather than a literal", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + require.True(t, strings.HasPrefix(resolve(t, ""), "unknown_service"), resolve(t, "")) + }) +} + +// The identity is the one the SDK built its provider with, the same one wool +// stamps on this process's log lines — read from the provider, not from the +// runtime's environment carrier. +func TestCodeflyServiceNameIsTheSDKProviderIdentity(t *testing.T) { + carrying := func(unique string) context.Context { + return wool.New(t.Context(), &wool.Resource{Kind: "service", Unique: unique}).Inject(t.Context()) + } + require.Equal(t, "saas-starter/accounts", codeflyServiceName(carrying("saas-starter/accounts"))) + require.Equal(t, "accounts", codeflyServiceName(carrying("/accounts")), "an identity with no module") + require.Empty(t, codeflyServiceName(carrying("/")), "an identity with neither") + require.Empty(t, codeflyServiceName(t.Context()), "no provider: the SDK was not initialised") +} + +func restoreTracing(t *testing.T) { + t.Helper() + previous := otel.GetTracerProvider() + t.Cleanup(func() { + wool.RegisterTelemetry(nil) + otel.SetTracerProvider(previous) + }) +} + +// With no endpoint delivered the process registers no OTLP exporter: nothing +// is exported, and nothing dials a default address. +func TestEnableTracingWithoutAnEndpointRegistersNothingOutsideALocalRun(t *testing.T) { + restoreTracing(t) + isolateOTLPEnvironment(t) + provider, err := enableTracing(t.Context(), false) + require.NoError(t, err) + require.Nil(t, provider) + require.False(t, wool.TelemetryEnabled()) +} + +// A local run with no endpoint keeps wool's own stdout tracer, which exports +// nothing over OTLP. +func TestEnableTracingWithoutAnEndpointUsesWoolsStdoutTracerInALocalRun(t *testing.T) { + restoreTracing(t) + isolateOTLPEnvironment(t) + provider, err := enableTracing(t.Context(), true) + require.NoError(t, err) + require.NotNil(t, provider) + require.True(t, wool.TelemetryEnabled()) + shutdown(t, provider) +} + +// The endpoint decides, not where the process runs: a pod deployed into an +// environment named `local` that is handed a collector exports to it, rather +// than falling back to the stdout tracer. +func TestEnableTracingExportsToTheDeliveredEndpointWhetherOrNotTheRunIsLocal(t *testing.T) { + for name, local := range map[string]bool{"deployed": false, "local environment": true} { + t.Run(name, func(t *testing.T) { + restoreTracing(t) + t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + endpoint, capture := startTraceCapture(t) + collectorAt(t, "http", endpoint) + provider, err := enableTracing(t.Context(), local) + require.NoError(t, err) + require.NotNil(t, provider) + require.True(t, wool.TelemetryEnabled()) + + _, span := otel.Tracer("test").Start(t.Context(), "operation") + span.End() + shutdown(t, provider) // flushes the batch + + select { + case request := <-capture.requests: + var name string + for _, resourceSpans := range request.GetResourceSpans() { + for _, attribute := range resourceSpans.GetResource().GetAttributes() { + if attribute.GetKey() == "service.name" { + name = attribute.GetValue().GetStringValue() + } + } + } + require.Equal(t, "named-by-the-platform", name) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for OTLP spans") + } + }) + } +} + +// core's wool/otel owns what an endpoint may be, and a value it refuses stops +// the process with its reason and registers nothing — never a tracer pointed at a +// default address. +func TestEnableTracingReturnsWhatWoolRefusesAndRegistersNothing(t *testing.T) { + for name, value := range map[string]string{ + "no port": "http://otel-collector.otel-collector.svc.cluster.local", + "userinfo": "http://user:secret-pass@collector.example:4317", + } { + t.Run(name, func(t *testing.T) { + restoreTracing(t) + isolateOTLPEnvironment(t) + t.Setenv(otlpEndpointVariable, value) + provider, err := enableTracing(t.Context(), false) + require.Error(t, err) + require.NotContains(t, err.Error(), "secret-pass", "the refusal never echoes the URL") + require.Nil(t, provider) + require.False(t, wool.TelemetryEnabled()) + }) + } +} + +// wool takes a value that is not an http:// or https:// URL for a bare +// host:port, the shape it accepted before it read URLs, and dials it in +// plaintext. The metrics exporter reads only a URL and, given anything else, +// keeps its default, localhost:4317. So the tracer would accept what the +// metrics exporter silently misreads, which is why the metrics call refuses it: +// both signals follow the one variable, and neither is pointed at a default. +func TestAValueWoolAcceptsAsHostPortIsRefusedByTheMetricsExporterGuard(t *testing.T) { + restoreTracing(t) + isolateOTLPEnvironment(t) + t.Setenv(otlpEndpointVariable, "collector.example:4317") + + provider, err := enableTracing(t.Context(), false) + require.NoError(t, err) + require.NotNil(t, provider) + shutdown(t, provider) + + metrics, err := enableOTELMetrics(t.Context()) + require.ErrorContains(t, err, "http:// or https://") + require.Nil(t, metrics) +} + +type traceCapture struct { + collectortracev1.UnimplementedTraceServiceServer + requests chan *collectortracev1.ExportTraceServiceRequest +} + +func (c *traceCapture) Export( + _ context.Context, + request *collectortracev1.ExportTraceServiceRequest, +) (*collectortracev1.ExportTraceServiceResponse, error) { + c.requests <- request + return &collectortracev1.ExportTraceServiceResponse{}, nil +} + +// startTraceCapture serves an in-process OTLP/gRPC trace receiver on loopback. +func startTraceCapture(t *testing.T) (string, *traceCapture) { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + capture := &traceCapture{requests: make(chan *collectortracev1.ExportTraceServiceRequest, 4)} + server := grpc.NewServer() + collectortracev1.RegisterTraceServiceServer(server, capture) + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + return listener.Addr().String(), capture +} + +func shutdown(t *testing.T, provider interface{ Shutdown(context.Context) error }) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, provider.Shutdown(ctx)) +} diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 7f1f1fe21..5350e7597 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -82,40 +82,30 @@ func doWork(ctx context.Context) (Clean, error) { return nil, err } - // Traces and metrics go to the cell's collector, whose address the platform - // delivers in the `observability` configuration group — or the group says the - // cell has none. Either answer was already required before anything was - // acquired (requireStartupConfiguration); this reads it again to act on it. - telemetryDestination, err := configuredTelemetryDestination(codefly.IsLocal()) - if err != nil { - return nil, err - } - // A local run with no cell collector gets wool's own stdout tracer instead. - otelProvider, err := enableTracing(ctx, telemetryDestination, codefly.IsLocal()) + // Traces and metrics go to the collector OTEL_EXPORTER_OTLP_ENDPOINT names, which + // the platform delivers when the cell has one this workload may reach. With none + // delivered the process exports nothing over OTLP; a local run's traces go to + // wool's own stdout tracer instead. wool/otel reads and validates the variable, + // so a value it refuses stops the process here with its reason. + otelProvider, err := enableTracing(ctx, codefly.IsLocal()) if err != nil { return nil, fmt.Errorf("configure OTEL tracing: %w", err) } var otelMetricProvider *otelMetrics - if telemetryDestination.Available() { - metricProvider, oerr := enableOTELMetrics(ctx, telemetryDestination) + if otlpEndpointConfigured() { + metricProvider, oerr := enableOTELMetrics(ctx) if oerr != nil { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - _ = otelProvider.Shutdown(shutdownCtx) - cancel() + if otelProvider != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = otelProvider.Shutdown(shutdownCtx) + cancel() + } return nil, fmt.Errorf("configure OTEL metrics: %w", oerr) } otelMetricProvider = metricProvider - w.Info("OTEL enabled", - wool.Field("endpoint", telemetryDestination.Endpoint), - wool.Field("insecure", telemetryDestination.Insecure)) + w.Info("OTEL export enabled: traces and metrics go to the collector at " + otlpEndpointVariable) } else { - w.Info("OTEL export disabled: the cell has no collector", - wool.Field("reason", telemetryDestination.AbsentReason)) - } - // The state decided; a key it does not use is a leftover, reported here once. - // requireStartupConfiguration reads the group too, but only to refuse. - if notice := telemetryDestination.IgnoredNotice(); notice != "" { - w.Warn(notice) + w.Info("OTEL export disabled: " + otlpEndpointVariable + " is not set, so nothing is exported over OTLP") } store, err := infra.NewPostgresStore(ctx) @@ -2542,21 +2532,7 @@ func requireStartupConfiguration(ctx context.Context, isLocal bool) error { if err != nil { return err } - if err := requireKeyCustody(selection.SigningBackend, isLocal); err != nil { - return err - } - return requireTelemetryConfiguration(isLocal) -} - -// requireTelemetryConfiguration refuses to start unless the `observability` -// group says where the cell's collector is, or that the cell has none. It only -// checks: doWork reads the same answer again to act on it. A group that did not -// arrive is refused outside the local runtime, before the database or Vault is -// touched: reading it as "no collector" would leave a cell exporting nothing -// while it converges green. -func requireTelemetryConfiguration(isLocal bool) error { - _, err := configuredTelemetryDestination(isLocal) - return err + return requireKeyCustody(selection.SigningBackend, isLocal) } // requireKeyCustody refuses to start outside the local environment without the diff --git a/module/services/auth-gateway/code/main.go b/module/services/auth-gateway/code/main.go index 284d99ae7..4995daa65 100644 --- a/module/services/auth-gateway/code/main.go +++ b/module/services/auth-gateway/code/main.go @@ -33,14 +33,6 @@ func main() { panic(fmt.Sprintf("codefly init failed: %v", err)) } - // The cell's collector is delivered in the `observability` configuration - // group, or the group says the cell has none. A group that did not arrive is - // neither, so it is refused outside a local runtime before anything else starts. - telemetryDestination, err := configuredTelemetryDestination(codefly.IsLocal()) - if err != nil { - panic(err) - } - // The SDK resolves an endpoint from the carrier the runtime injected or, in a // local run, from the workspace's endpoint map, and never invents a port. grpcNet, grpcNetErr := codefly.For(ctx).API(standards.GRPC).ResolveNetworkInstance() @@ -54,27 +46,29 @@ func main() { panic(fmt.Sprintf("Codefly REST endpoint is unavailable: %v", httpNetErr)) } httpPort = httpNet.Port - // A local run with no cell collector gets wool's own stdout tracer instead. - otelProvider, err := enableTracing(ctx, telemetryDestination, codefly.IsLocal()) + // Traces and metrics go to the collector OTEL_EXPORTER_OTLP_ENDPOINT names, which + // the platform delivers when the cell has one this workload may reach. With none + // delivered the process exports nothing over OTLP; a local run's traces go to + // wool's own stdout tracer instead. wool/otel reads and validates the variable, + // so a value it refuses stops the process here with its reason. + otelProvider, err := enableTracing(ctx, codefly.IsLocal()) if err != nil { panic(fmt.Sprintf("configure OTEL tracing: %v", err)) } var otelMetricProvider *otelMetrics - if telemetryDestination.Available() { - otelMetricProvider, err = enableOTELMetrics(ctx, telemetryDestination) + if otlpEndpointConfigured() { + otelMetricProvider, err = enableOTELMetrics(ctx) if err != nil { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - _ = otelProvider.Shutdown(shutdownCtx) - cancel() + if otelProvider != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = otelProvider.Shutdown(shutdownCtx) + cancel() + } panic(fmt.Sprintf("configure OTEL metrics: %v", err)) } - log.Printf("OTEL enabled: endpoint=%s insecure=%t", telemetryDestination.Endpoint, telemetryDestination.Insecure) + log.Print("OTEL export enabled: traces and metrics go to the collector at " + otlpEndpointVariable) } else { - log.Printf("OTEL export disabled: the cell has no collector: %s", telemetryDestination.AbsentReason) - } - // The state decided; a key it does not use is a leftover, reported here once. - if notice := telemetryDestination.IgnoredNotice(); notice != "" { - log.Print(notice) + log.Print("OTEL export disabled: " + otlpEndpointVariable + " is not set, so nothing is exported over OTLP") } defer func() { shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/module/services/auth-gateway/code/telemetry.go b/module/services/auth-gateway/code/telemetry.go new file mode 100644 index 000000000..f25d0d03e --- /dev/null +++ b/module/services/auth-gateway/code/telemetry.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "strings" + + "github.com/codefly-dev/core/wool" + wooltel "github.com/codefly-dev/core/wool/otel" + codefly "github.com/codefly-dev/sdk-go" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/sdk/resource" +) + +// otlpEndpointVariable is the whole telemetry contract: OpenTelemetry's own name +// for the collector's address. The platform delivers it when the cell has a +// collector this workload may reach, and delivers nothing when it does not. +// +// Deliver it and the process exports traces and metrics over OTLP/gRPC; deliver +// none and it exports nothing over OTLP. Nothing here reads a state, a reason or +// a protocol: core's wool/otel reads this variable itself, tells a URL from a +// host:port, takes the transport from the scheme, validates the URL and refuses a +// portless one, and the metrics exporter reads it from the same environment. +const otlpEndpointVariable = "OTEL_EXPORTER_OTLP_ENDPOINT" + +// otlpEndpointConfigured reports whether the process was given a collector. A +// whitespace-only value is none: the OpenTelemetry metrics exporter trims what it +// reads and treats an empty result as unset, and an exporter with nothing +// configured dials its own default, localhost:4317. +func otlpEndpointConfigured() bool { + return strings.TrimSpace(os.Getenv(otlpEndpointVariable)) != "" +} + +// enableTracing registers wool's tracer: OTLP/gRPC to the collector when the +// environment names one, with wool's own stdout tracer in its place on a local +// run that names none, and no tracer at all otherwise. A nil provider means +// nothing was registered. +// +// wool/otel owns the endpoint: it reads the variable, so a value it refuses (no +// scheme, no port, credentials in the URL) is returned as its error and nothing is +// registered. +func enableTracing(ctx context.Context, local bool) (*wooltel.Provider, error) { + configured := otlpEndpointConfigured() + if !configured && !local { + return nil, nil + } + res, err := currentTelemetryResource(ctx) + if err != nil { + return nil, fmt.Errorf("resolve telemetry resource: %w", err) + } + options := []wooltel.Option{wooltel.WithServiceName(telemetryServiceName(res))} + if !configured { + options = append(options, wooltel.WithStdout()) + } + return wooltel.Enable(options...) +} + +// requireMetricsEndpoint refuses what the metrics exporter cannot read. It reads +// the variable only as an http:// or https:// URL, and on anything else keeps its +// default and dials localhost:4317 without saying so, so a value of another shape +// (wool still accepts a bare host:port for traces) stops the process here. +func requireMetricsEndpoint() error { + if !otlpEndpointConfigured() { + return errors.New("no " + otlpEndpointVariable + " to export metrics to") + } + parsed, err := url.Parse(strings.TrimSpace(os.Getenv(otlpEndpointVariable))) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return errors.New(otlpEndpointVariable + " must be an http:// or https:// URL for metrics to be exported; the exporter ignores any other form and would fall back to its own default address") + } + return nil +} + +// telemetryResource is the one resource both signals carry. service.name is +// whatever the environment says — OTEL_SERVICE_NAME, or service.name in +// OTEL_RESOURCE_ATTRIBUTES — and otherwise the service identity the Codefly SDK +// holds. No name is typed here: a literal would override the environment, so the +// platform's identity could never reach the backend. +func telemetryResource(ctx context.Context, identity string) (*resource.Resource, error) { + options := []resource.Option{resource.WithService()} + if identity != "" { + options = append(options, resource.WithAttributes(attribute.String("service.name", identity))) + } + // Last, so what the environment declares wins over the identity above. + options = append(options, resource.WithFromEnv()) + return resource.New(ctx, options...) +} + +// currentTelemetryResource is telemetryResource for this process. +func currentTelemetryResource(ctx context.Context) (*resource.Resource, error) { + return telemetryResource(ctx, codeflyServiceName(codefly.Context())) +} + +// codeflyServiceName is the service identity the Codefly SDK holds: the +// `module/service` identity of the provider it built at Init, which wool stamps on +// every log line from this process. It is empty when the SDK has not been +// initialised. The SDK exposes no accessor of its own, and the environment +// carrier it reads this from is not product code's to read, so this asks the +// provider. +func codeflyServiceName(ctx context.Context) string { + provider, ok := ctx.Value(wool.ProviderKey).(*wool.Provider) + if !ok || provider == nil { + return "" + } + source := provider.Get(ctx).Source() + if source == nil { + return "" + } + // An identity with no module reads `/service`; with neither, `/`. + return strings.Trim(strings.TrimSpace(source.Unique), "/") +} + +// telemetryServiceName is the service.name the resource resolved to. +func telemetryServiceName(res *resource.Resource) string { + if value, ok := res.Set().Value(attribute.Key("service.name")); ok { + return value.AsString() + } + return "" +} diff --git a/module/services/auth-gateway/code/telemetry_destination.go b/module/services/auth-gateway/code/telemetry_destination.go deleted file mode 100644 index 650372193..000000000 --- a/module/services/auth-gateway/code/telemetry_destination.go +++ /dev/null @@ -1,326 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "net" - "net/url" - "strings" - - "github.com/codefly-dev/core/wool" - wooltel "github.com/codefly-dev/core/wool/otel" - codefly "github.com/codefly-dev/sdk-go" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/sdk/resource" -) - -// Where this process sends traces and metrics — or that it sends none. -// -// The platform tells a workload through the `observability` configuration -// group, and the group always says one of two things: -// -// TELEMETRY_STATE=available with OTEL_EXPORTER_OTLP_ENDPOINT, the cell's -// collector (OTLP/gRPC), e.g. -// http://otel-collector.otel-collector.svc.cluster.local:4317 -// TELEMETRY_STATE=absent with TELEMETRY_ABSENT_REASON, why the cell has no -// collector -// -// `http://` is plaintext on the wire because the service mesh supplies mTLS -// between workloads; `https://` is TLS, which the metrics exporter dials but -// wool's tracer cannot yet (see errWoolCannotDialTLS). This module owns no -// collector: the cell does, and a workload that cannot find it does not -// substitute one. -// -// The state decides. The group is layered, so a key the state does not use can -// still arrive from a lower layer: it is ignored, not refused, and reported once -// through IgnoredNotice. What each state requires is unchanged: `available` needs -// an endpoint, `absent` needs a reason. The module's own profile ships neither -// the state nor the reason, so a cell that delivers `available` carries no -// leftover and a cell that delivers `absent` without a reason is refused. -// -// A group that did not arrive is not a cell without a backend. Reading the first -// as the second would leave a deployment that converges green and exports -// nothing, so a missing or unknown state, or the key the state requires being -// absent, refuses to start, naming what is missing. Only a local runtime may -// infer `absent` when the state is missing, and it supplies the reason itself -// (localAbsentReason): module defaults declare neither. -// -// This file exists byte for byte in services/accounts/code and -// services/auth-gateway/code: the two services are independent Go modules with -// no shared package, and module/tools/telemetry_destination_mirror_test.go holds -// the copies identical. Change one, copy it over the other. - -const ( - telemetryStateKey = "TELEMETRY_STATE" - telemetryEndpointKey = "OTEL_EXPORTER_OTLP_ENDPOINT" - telemetryAbsentReasonKey = "TELEMETRY_ABSENT_REASON" - - telemetryStateAvailable = "available" - telemetryStateAbsent = "absent" - - // maxAbsentReasonLength bounds the operator-written reason before it reaches - // a log line: an unbounded value from configuration would otherwise let - // whoever writes the group forge log entries around it. - maxAbsentReasonLength = 300 - - // localAbsentReason is the reason a local run gives for having no collector. - // It lives in code, and is used only when the process is local, because the - // module's configuration profile is inherited by deployed cells: a reason - // shipped there would reach a cell that sent `absent` without saying why. - localAbsentReason = "A local run has no cell collector; traces go to the stdout tracer and metrics are not exported." -) - -// telemetryDestination is the resolved answer. Exactly one of URL and -// AbsentReason is set. -type telemetryDestination struct { - // URL is the collector's origin as scheme://host:port, the port always - // explicit, which is the form otlpmetricgrpc.WithEndpointURL takes: the - // exporter reads its transport off the scheme. Empty when the cell has no - // collector. - URL string - // Endpoint is the same address as host:port. wool's tracer takes it in this - // form and has no option that reads a URL. Empty when the cell has no - // collector. - Endpoint string - // Insecure is true when the endpoint was http://, which the mesh protects. - // wool's tracer needs it spelled out; it is derived from the endpoint's - // scheme and never assumed. - Insecure bool - // AbsentReason says why the cell has no collector. Empty when URL is set. - AbsentReason string - // Ignored names the key the group carried that has no meaning for the state - // that decided: the absent reason under `available`, the endpoint under - // `absent`. Empty when the group carried nothing extra. - Ignored string -} - -// Available reports whether the cell has a collector to export to. -func (d telemetryDestination) Available() bool { - return d.Endpoint != "" -} - -// IgnoredNotice is the one line to log, once, when the group carried a key the -// state does not use. It is empty otherwise. It names the key and never its -// value: a leftover endpoint may carry credentials, and a leftover reason is -// free text. -func (d telemetryDestination) IgnoredNotice() string { - if d.Ignored == "" { - return "" - } - state := telemetryStateAbsent - if d.Available() { - state = telemetryStateAvailable - } - return fmt.Sprintf( - "observability: %s is set but %s is `%s`, which decides; %s is ignored (a leftover from a lower layer of the group)", - d.Ignored, telemetryStateKey, state, d.Ignored) -} - -// observabilityValue reads one key of the `observability` group through the -// SDK. It deliberately has no fallback to a bare process variable: an ambient -// OTEL_EXPORTER_OTLP_ENDPOINT in a shell or a pod is conventionally the -// OTLP/HTTP address, not the cell's gRPC collector, and reading it here would -// export to the wrong place without a word. -func observabilityValue(key string) string { - value, err := codefly.For(codefly.Context()).WorkspaceValue("observability", key) - if err != nil { - return "" - } - return value -} - -// configuredTelemetryDestination resolves the destination from the -// `observability` group. -func configuredTelemetryDestination(local bool) (telemetryDestination, error) { - return resolveTelemetryDestination(local, observabilityValue) -} - -// resolveTelemetryDestination is the whole decision, over any reader of the -// group's keys so a test can reach every outcome with nothing running. -func resolveTelemetryDestination(local bool, read func(key string) string) (telemetryDestination, error) { - state := strings.TrimSpace(read(telemetryStateKey)) - endpoint := strings.TrimSpace(read(telemetryEndpointKey)) - reason := boundedLine(read(telemetryAbsentReasonKey), maxAbsentReasonLength) - - if state == "" && local { - state = telemetryStateAbsent - if reason == "" { - reason = localAbsentReason - } - } - if state == "" { - return telemetryDestination{}, errors.New( - "observability: " + telemetryStateKey + " is not set in the `observability` configuration group. " + - "The platform delivers `" + telemetryStateAvailable + "` (with " + telemetryEndpointKey + ") or `" + - telemetryStateAbsent + "` (with " + telemetryAbsentReasonKey + "); a group that did not arrive is " + - "not a cell without a collector, so this process does not start without it") - } - if state != telemetryStateAvailable && state != telemetryStateAbsent { - return telemetryDestination{}, fmt.Errorf( - "observability: %s is %q in the `observability` configuration group; it must be `%s` or `%s`", - telemetryStateKey, state, telemetryStateAvailable, telemetryStateAbsent) - } - - // The state decides: the key that belongs to the other state is a leftover - // of a lower layer and is neither read nor refused. The endpoint under - // `absent` is not even parsed, so a stale one cannot stop a cell that says - // it has no collector. - if state == telemetryStateAbsent { - if reason == "" { - return telemetryDestination{}, fmt.Errorf( - "observability: %s is `%s` but %s is not set in the `observability` configuration group; "+ - "an absent collector must say why", - telemetryStateKey, telemetryStateAbsent, telemetryAbsentReasonKey) - } - destination := telemetryDestination{AbsentReason: reason} - if endpoint != "" { - destination.Ignored = telemetryEndpointKey - } - return destination, nil - } - - if endpoint == "" { - return telemetryDestination{}, fmt.Errorf( - "observability: %s is `%s` but %s is not set in the `observability` configuration group", - telemetryStateKey, telemetryStateAvailable, telemetryEndpointKey) - } - origin, hostPort, insecure, err := parseCollectorEndpoint(endpoint) - if err != nil { - return telemetryDestination{}, fmt.Errorf("observability: %s: %w", telemetryEndpointKey, err) - } - destination := telemetryDestination{URL: origin, Endpoint: hostPort, Insecure: insecure} - if reason != "" { - destination.Ignored = telemetryAbsentReasonKey - } - return destination, nil -} - -// parseCollectorEndpoint turns the delivered URL into the two forms the -// exporters take — the origin with an explicit port, and host:port — and reads -// the transport off its scheme. The URL is never echoed back: a malformed one -// may carry credentials. -func parseCollectorEndpoint(raw string) (origin, hostPort string, insecure bool, err error) { - parsed, parseErr := url.Parse(raw) - if parseErr != nil { - return "", "", false, errors.New("is not a URL; expected http://host:port or https://host:port") - } - switch parsed.Scheme { - case "http": - insecure = true - case "https": - default: - return "", "", false, fmt.Errorf("must start with http:// or https://, got scheme %q", parsed.Scheme) - } - if parsed.Hostname() == "" { - return "", "", false, errors.New("has no host") - } - if parsed.User != nil { - return "", "", false, errors.New("must not carry credentials") - } - // OTLP/gRPC addresses a collector, not a path: a path or a query is the - // OTLP/HTTP form (/v1/traces), which this exporter does not speak. - if (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { - return "", "", false, errors.New("must be the collector's origin only, with no path, query or fragment") - } - // The port is made explicit because gRPC would otherwise dial 443 whatever - // the scheme, and a plaintext http:// collector with no port is not on 443. - port := parsed.Port() - if port == "" { - port = "80" - if !insecure { - port = "443" - } - } - hostPort = net.JoinHostPort(parsed.Hostname(), port) - return parsed.Scheme + "://" + hostPort, hostPort, insecure, nil -} - -// boundedLine collapses a free-text value to one bounded line. -func boundedLine(value string, limit int) string { - line := strings.Join(strings.Fields(value), " ") - if runes := []rune(line); len(runes) > limit { - return string(runes[:limit]) + "…" - } - return line -} - -// telemetryResource is the one resource both signals carry. service.name is -// whatever the environment says — OTEL_SERVICE_NAME, or service.name in -// OTEL_RESOURCE_ATTRIBUTES — and otherwise the service identity the Codefly SDK -// holds. No name is typed here: a literal would override the environment, so the -// platform's identity could never reach the backend. -func telemetryResource(ctx context.Context, identity string) (*resource.Resource, error) { - options := []resource.Option{resource.WithService()} - if identity != "" { - options = append(options, resource.WithAttributes(attribute.String("service.name", identity))) - } - // Last, so what the environment declares wins over the identity above. - options = append(options, resource.WithFromEnv()) - return resource.New(ctx, options...) -} - -// currentTelemetryResource is telemetryResource for this process. -func currentTelemetryResource(ctx context.Context) (*resource.Resource, error) { - return telemetryResource(ctx, codeflyServiceName(codefly.Context())) -} - -// codeflyServiceName is the service identity the Codefly SDK holds: the -// `module/service` identity of the provider it built at Init, which wool stamps on -// every log line from this process. It is empty when the SDK has not been -// initialised. The SDK exposes no accessor of its own, and the environment -// carrier it reads this from is not product code's to read, so this asks the -// provider. -func codeflyServiceName(ctx context.Context) string { - provider, ok := ctx.Value(wool.ProviderKey).(*wool.Provider) - if !ok || provider == nil { - return "" - } - source := provider.Get(ctx).Source() - if source == nil { - return "" - } - // An identity with no module reads `/service`; with neither, `/`. - return strings.Trim(strings.TrimSpace(source.Unique), "/") -} - -// telemetryServiceName is the service.name the resource resolved to. -func telemetryServiceName(res *resource.Resource) string { - if value, ok := res.Set().Value(attribute.Key("service.name")); ok { - return value.AsString() - } - return "" -} - -// errWoolCannotDialTLS names the one transport the tracer cannot serve. -var errWoolCannotDialTLS = errors.New( - "the collector endpoint is https://, but wool's OTLP tracer cannot dial TLS: wool/otel.Enable " + - "always sets an insecure transport and offers no option to turn it off. Deliver an http:// " + - "endpoint (in-mesh, where the mesh supplies mTLS) until wool can dial TLS") - -// enableTracing registers wool's tracer for the destination: OTLP/gRPC to the -// cell's collector when there is one; wool's own stdout tracer when there is -// none and this is a local run; nothing otherwise. A nil provider means nothing -// was registered. -func enableTracing(ctx context.Context, destination telemetryDestination, local bool) (*wooltel.Provider, error) { - res, err := currentTelemetryResource(ctx) - if err != nil { - return nil, fmt.Errorf("resolve telemetry resource: %w", err) - } - options := []wooltel.Option{wooltel.WithServiceName(telemetryServiceName(res))} - switch { - case destination.Available(): - // wool dials plaintext unless told otherwise and cannot be told - // otherwise, so an https:// endpoint is refused rather than dialed in - // the clear and reported as TLS. - if !destination.Insecure { - return nil, errWoolCannotDialTLS - } - options = append(options, wooltel.WithEndpoint(destination.Endpoint), wooltel.WithInsecure()) - case local: - options = append(options, wooltel.WithStdout()) - default: - return nil, nil - } - return wooltel.Enable(options...) -} diff --git a/module/services/auth-gateway/code/telemetry_destination_test.go b/module/services/auth-gateway/code/telemetry_destination_test.go deleted file mode 100644 index 63cb2077e..000000000 --- a/module/services/auth-gateway/code/telemetry_destination_test.go +++ /dev/null @@ -1,603 +0,0 @@ -package main - -import ( - "context" - "net" - "os" - "strings" - "testing" - "time" - - "github.com/codefly-dev/core/wool" - "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel" - collectortracev1 "go.opentelemetry.io/proto/otlp/collector/trace/v1" - "google.golang.org/grpc" -) - -// group is a reader over a fixed set of `observability` keys. -func group(values map[string]string) func(string) string { - return func(key string) string { return values[key] } -} - -const collector = "http://otel-collector.otel-collector.svc.cluster.local:4317" - -// shippedObservabilityProfile is what the module's own `observability` profile -// contributes to the group, read from the file it ships. -func shippedObservabilityProfile(t *testing.T) map[string]string { - t.Helper() - contents, err := os.ReadFile("../../../configurations/local/observability.env") - require.NoError(t, err) - values := map[string]string{} - for _, line := range strings.Split(string(contents), "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - key, value, ok := strings.Cut(line, "=") - require.True(t, ok) - values[key] = value - } - return values -} - -// Module defaults are also inherited by cells, so the profile ships neither the -// state nor the reason: a deployed workload must get both from the platform, and -// a local run gets them from code (localAbsentReason), only when it is local. -func TestTelemetryDestinationShippedProfileDeclaresNothingACellCouldInherit(t *testing.T) { - shipped := shippedObservabilityProfile(t) - require.NotContains(t, shipped, "TELEMETRY_STATE", "every deployed cell inherits the profile") - require.NotContains(t, shipped, "TELEMETRY_ABSENT_REASON", "every deployed cell inherits the profile") - require.Empty(t, shipped["OTEL_EXPORTER_OTLP_ENDPOINT"], "the profile keeps the group in existence and names no collector") - - _, err := resolveTelemetryDestination(false, group(shipped)) - require.ErrorContains(t, err, "TELEMETRY_STATE is not set") - destination, err := resolveTelemetryDestination(true, group(shipped)) - require.NoError(t, err) - require.False(t, destination.Available()) - require.Equal(t, localAbsentReason, destination.AbsentReason, "a local run supplies its own reason in code") - require.Empty(t, destination.IgnoredNotice()) -} - -// Layered over the shipped profile, a healthy cell leaves nothing to warn about, -// and a cell that says `absent` without a reason is refused rather than passed -// with the local-run reason. -func TestTelemetryDestinationCellAnswersAreNotMaskedByTheShippedProfile(t *testing.T) { - shipped := shippedObservabilityProfile(t) - - available, err := resolveTelemetryDestination(false, layered(shipped, map[string]string{ - "TELEMETRY_STATE": "available", - "OTEL_EXPORTER_OTLP_ENDPOINT": collector, - })) - require.NoError(t, err) - require.True(t, available.Available()) - require.Empty(t, available.Ignored, "an available cell must not log a leftover-key warning on every boot") - require.Empty(t, available.IgnoredNotice()) - - _, err = resolveTelemetryDestination(false, layered(shipped, map[string]string{"TELEMETRY_STATE": "absent"})) - require.ErrorContains(t, err, "TELEMETRY_ABSENT_REASON is not set") - - // The same answer from a local run is refused too: the local reason fills in - // a missing state, never an explicit `absent` that did not say why. - _, err = resolveTelemetryDestination(true, layered(shipped, map[string]string{"TELEMETRY_STATE": "absent"})) - require.ErrorContains(t, err, "TELEMETRY_ABSENT_REASON is not set") -} - -func TestTelemetryDestinationMissingStateDefaultsOnlyInLocalRuntime(t *testing.T) { - for _, values := range []map[string]string{ - {}, - {"TELEMETRY_STATE": " "}, - {"TELEMETRY_ABSENT_REASON": "local defaults inherited by a cell"}, - {"OTEL_EXPORTER_OTLP_ENDPOINT": collector}, - } { - _, err := resolveTelemetryDestination(false, group(values)) - require.ErrorContains(t, err, "TELEMETRY_STATE is not set") - destination, err := resolveTelemetryDestination(true, group(values)) - require.NoError(t, err) - require.False(t, destination.Available()) - require.NotEmpty(t, destination.AbsentReason) - } - for _, values := range []map[string]string{ - {"TELEMETRY_STATE": "unknown"}, - {"TELEMETRY_STATE": "available"}, - {"TELEMETRY_STATE": "absent"}, - } { - _, err := resolveTelemetryDestination(true, group(values)) - require.Error(t, err, "an explicit state must be valid and complete even locally") - } -} - -// The first outcome: the platform delivered a collector. -func TestTelemetryDestinationAvailableExportsToTheEndpoint(t *testing.T) { - destination, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "available", - "OTEL_EXPORTER_OTLP_ENDPOINT": collector, - })) - require.NoError(t, err) - require.True(t, destination.Available()) - require.Equal(t, collector, destination.URL, "the metrics exporter reads its transport off this URL") - require.Equal(t, "otel-collector.otel-collector.svc.cluster.local:4317", destination.Endpoint) - require.True(t, destination.Insecure, "http:// is plaintext on the wire; the mesh supplies mTLS") - require.Empty(t, destination.AbsentReason) - require.Empty(t, destination.Ignored) - require.Empty(t, destination.IgnoredNotice()) -} - -// The transport comes from the endpoint's scheme, never from a constant, and -// the URL handed to the metrics exporter always carries an explicit port: gRPC -// would otherwise dial 443 whatever the scheme. -func TestTelemetryDestinationReadsTransportFromTheScheme(t *testing.T) { - for endpoint, want := range map[string]struct { - url string - hostPort string - insecure bool - }{ - "http://collector.example:4317": {"http://collector.example:4317", "collector.example:4317", true}, - "https://collector.example:4317": {"https://collector.example:4317", "collector.example:4317", false}, - "https://collector.example": {"https://collector.example:443", "collector.example:443", false}, - "http://collector.example": {"http://collector.example:80", "collector.example:80", true}, - "http://collector.example:4317/": {"http://collector.example:4317", "collector.example:4317", true}, - " http://collector.example:4317 ": {"http://collector.example:4317", "collector.example:4317", true}, - "http://[2001:db8::1]:4317": {"http://[2001:db8::1]:4317", "[2001:db8::1]:4317", true}, - "https://10.0.0.7:4317": {"https://10.0.0.7:4317", "10.0.0.7:4317", false}, - "http://otel-collector.ns.svc:4317/": {"http://otel-collector.ns.svc:4317", "otel-collector.ns.svc:4317", true}, - } { - destination, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "available", - "OTEL_EXPORTER_OTLP_ENDPOINT": endpoint, - })) - require.NoError(t, err, endpoint) - require.Equal(t, want.url, destination.URL, endpoint) - require.Equal(t, want.hostPort, destination.Endpoint, endpoint) - require.Equal(t, want.insecure, destination.Insecure, endpoint) - } -} - -// The second outcome: the cell has no collector, and says why. The process boots -// and exports nothing. -func TestTelemetryDestinationAbsentCarriesTheReason(t *testing.T) { - destination, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "absent", - "TELEMETRY_ABSENT_REASON": "This cell's bucket has no ops cell, so no collector is rendered.", - })) - require.NoError(t, err) - require.False(t, destination.Available()) - require.Empty(t, destination.URL) - require.Empty(t, destination.Endpoint) - require.Equal(t, "This cell's bucket has no ops cell, so no collector is rendered.", destination.AbsentReason) - require.Empty(t, destination.Ignored) - require.Empty(t, destination.IgnoredNotice()) -} - -// The reason is operator-written free text that reaches a log line. -func TestTelemetryDestinationAbsentReasonIsOneBoundedLine(t *testing.T) { - destination, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "absent", - "TELEMETRY_ABSENT_REASON": "no collector\n2026-01-01 FORGED entry\r\n" + strings.Repeat("x", 1000), - })) - require.NoError(t, err) - require.NotContains(t, destination.AbsentReason, "\n") - require.NotContains(t, destination.AbsentReason, "\r") - require.LessOrEqual(t, len([]rune(destination.AbsentReason)), maxAbsentReasonLength+1) -} - -// The third outcome: anything else refuses to start, naming what is wrong. -// Configuration that did not arrive is never read as "no collector". A key the -// state does not use is not a refusal — it is ignored, below — but a key the -// state requires, missing, always is. -func TestTelemetryDestinationRefusesEverythingElse(t *testing.T) { - for name, tc := range map[string]struct { - values map[string]string - want string - }{ - "state missing": { - values: map[string]string{}, - want: "TELEMETRY_STATE is not set", - }, - "state missing though an ambient endpoint is present": { - values: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": collector}, - want: "TELEMETRY_STATE is not set", - }, - "state missing though a reason is present": { - values: map[string]string{"TELEMETRY_ABSENT_REASON": "no collector"}, - want: "TELEMETRY_STATE is not set", - }, - "state blank": { - values: map[string]string{"TELEMETRY_STATE": " "}, - want: "TELEMETRY_STATE is not set", - }, - "state unknown": { - values: map[string]string{"TELEMETRY_STATE": "disabled", "TELEMETRY_ABSENT_REASON": "no collector"}, - want: `TELEMETRY_STATE is "disabled"`, - }, - "state is not matched case-insensitively": { - values: map[string]string{"TELEMETRY_STATE": "Available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, - want: `TELEMETRY_STATE is "Available"`, - }, - "available without an endpoint": { - values: map[string]string{"TELEMETRY_STATE": "available"}, - want: "OTEL_EXPORTER_OTLP_ENDPOINT is not set", - }, - "available with only a reason": { - values: map[string]string{"TELEMETRY_STATE": "available", "TELEMETRY_ABSENT_REASON": "no collector"}, - want: "OTEL_EXPORTER_OTLP_ENDPOINT is not set", - }, - "absent without a reason": { - values: map[string]string{"TELEMETRY_STATE": "absent"}, - want: "TELEMETRY_ABSENT_REASON is not set", - }, - "absent with a blank reason": { - values: map[string]string{"TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": " \n "}, - want: "TELEMETRY_ABSENT_REASON is not set", - }, - "absent with only an endpoint": { - values: map[string]string{"TELEMETRY_STATE": "absent", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, - want: "TELEMETRY_ABSENT_REASON is not set", - }, - "endpoint without a scheme": { - values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "otel-collector.ns.svc:4317"}, - want: "must start with http:// or https://", - }, - "endpoint with another scheme": { - values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "grpc://otel-collector.ns.svc:4317"}, - want: `got scheme "grpc"`, - }, - "endpoint without a host": { - values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://:4317"}, - want: "has no host", - }, - "endpoint naming an OTLP/HTTP path": { - values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector.example:4318/v1/traces"}, - want: "origin only", - }, - "endpoint with a query": { - values: map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector.example:4317?x=1"}, - want: "origin only", - }, - } { - t.Run(name, func(t *testing.T) { - destination, err := resolveTelemetryDestination(false, group(tc.values)) - require.Error(t, err) - require.ErrorContains(t, err, tc.want) - require.False(t, destination.Available(), "a refusal must never read as a usable destination") - require.Empty(t, destination.AbsentReason, "a refusal must never read as an absent collector") - }) - } -} - -// The state decides. The group is layered, so a deployed producer can still see -// `available` and an endpoint from the cell beside a reason a lower layer (a -// consuming workspace's own profile, say) left behind. That is not a -// contradiction to refuse: the reason is ignored, and said so once. -func TestTelemetryDestinationAvailableIgnoresALeftoverReason(t *testing.T) { - destination, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "available", - "OTEL_EXPORTER_OTLP_ENDPOINT": collector, - "TELEMETRY_ABSENT_REASON": "A local run has no cell collector.", - })) - require.NoError(t, err) - require.True(t, destination.Available()) - require.Equal(t, collector, destination.URL) - require.Empty(t, destination.AbsentReason, "a leftover reason must not turn an available collector into an absent one") - require.Equal(t, "TELEMETRY_ABSENT_REASON", destination.Ignored) - notice := destination.IgnoredNotice() - require.Contains(t, notice, "TELEMETRY_ABSENT_REASON is set but TELEMETRY_STATE is `available`") - require.NotContains(t, notice, "A local run", "the notice names the key, never its free-text value") -} - -// ... and the other way round: a cell that says it has no collector is not -// refused for an endpoint a lower layer left behind, and that endpoint is not -// even parsed. -func TestTelemetryDestinationAbsentIgnoresALeftoverEndpoint(t *testing.T) { - for name, leftover := range map[string]string{ - "a usable endpoint": collector, - "a malformed endpoint": "otel-collector.ns.svc:4317", - "an endpoint with a user": "http://user:hunter2@collector.example:4317", - "an unreadable endpoint": "http://[::1", - } { - t.Run(name, func(t *testing.T) { - destination, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "absent", - "TELEMETRY_ABSENT_REASON": "This cell has no ops cell.", - "OTEL_EXPORTER_OTLP_ENDPOINT": leftover, - })) - require.NoError(t, err) - require.False(t, destination.Available(), "a leftover endpoint must not turn an absent collector into an available one") - require.Empty(t, destination.URL) - require.Empty(t, destination.Endpoint) - require.Equal(t, "This cell has no ops cell.", destination.AbsentReason) - require.Equal(t, "OTEL_EXPORTER_OTLP_ENDPOINT", destination.Ignored) - notice := destination.IgnoredNotice() - require.Contains(t, notice, "OTEL_EXPORTER_OTLP_ENDPOINT is set but TELEMETRY_STATE is `absent`") - require.NotContains(t, notice, "hunter2", "the notice names the key, never its value") - }) - } -} - -// A blank leftover is nothing left over. -func TestTelemetryDestinationBlankLeftoversAreNotNoticed(t *testing.T) { - available, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector, "TELEMETRY_ABSENT_REASON": " \n ", - })) - require.NoError(t, err) - require.Empty(t, available.IgnoredNotice()) - - absent, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "no collector", "OTEL_EXPORTER_OTLP_ENDPOINT": " ", - })) - require.NoError(t, err) - require.Empty(t, absent.IgnoredNotice()) -} - -// layered is what the platform hands a workload: each layer overrides the one -// below it key by key, so a key a higher layer does not set survives from the -// lower one. -func layered(layers ...map[string]string) func(string) string { - merged := map[string]string{} - for _, layer := range layers { - for key, value := range layer { - merged[key] = value - } - } - return group(merged) -} - -// The case this exists for: a lower layer of the group says `absent` with a -// reason, and a deployed cell overrides the state and the endpoint but not the -// reason. The cell's answer wins, whichever way it points. -func TestTelemetryDestinationTheCellOverridesALowerLayer(t *testing.T) { - moduleDefault := map[string]string{ - "TELEMETRY_STATE": "absent", - "TELEMETRY_ABSENT_REASON": "A local run has no cell collector.", - } - - t.Run("a cell with a collector overrides a lower layer's absent default", func(t *testing.T) { - destination, err := resolveTelemetryDestination(false, layered(moduleDefault, map[string]string{ - "TELEMETRY_STATE": "available", - "OTEL_EXPORTER_OTLP_ENDPOINT": collector, - })) - require.NoError(t, err) - require.True(t, destination.Available()) - require.Equal(t, collector, destination.URL) - require.Empty(t, destination.AbsentReason) - require.Equal(t, "TELEMETRY_ABSENT_REASON", destination.Ignored) - }) - - t.Run("a cell with no collector, over a default that had an endpoint, is absent", func(t *testing.T) { - destination, err := resolveTelemetryDestination(false, layered( - map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, - map[string]string{"TELEMETRY_STATE": "absent", "TELEMETRY_ABSENT_REASON": "This cell has no ops cell."}, - )) - require.NoError(t, err) - require.False(t, destination.Available()) - require.Equal(t, "This cell has no ops cell.", destination.AbsentReason) - require.Equal(t, "OTEL_EXPORTER_OTLP_ENDPOINT", destination.Ignored) - }) - - t.Run("a cell that overrides only the state still has to supply what it requires", func(t *testing.T) { - _, err := resolveTelemetryDestination(false, layered(moduleDefault, map[string]string{"TELEMETRY_STATE": "available"})) - require.ErrorContains(t, err, "OTEL_EXPORTER_OTLP_ENDPOINT is not set", - "the module default's reason is not an endpoint") - - _, err = resolveTelemetryDestination(false, layered( - map[string]string{"TELEMETRY_STATE": "available", "OTEL_EXPORTER_OTLP_ENDPOINT": collector}, - map[string]string{"TELEMETRY_STATE": "absent"}, - )) - require.ErrorContains(t, err, "TELEMETRY_ABSENT_REASON is not set", - "the module default's endpoint is not a reason") - }) -} - -// A refusal quotes no part of an endpoint, which may carry credentials. -func TestTelemetryDestinationRefusalDoesNotEchoCredentials(t *testing.T) { - _, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "available", - "OTEL_EXPORTER_OTLP_ENDPOINT": "http://user:hunter2@collector.example:4317", - })) - require.ErrorContains(t, err, "must not carry credentials") - require.NotContains(t, err.Error(), "hunter2") -} - -// The group is read through the SDK's workspace-configuration lookup, by the -// names the platform delivers them under. A bare process variable of the same -// name is not the cell's answer. -func TestConfiguredTelemetryDestinationReadsTheObservabilityGroup(t *testing.T) { - const prefix = "CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__" - for _, key := range []string{"TELEMETRY_STATE", "OTEL_EXPORTER_OTLP_ENDPOINT", "TELEMETRY_ABSENT_REASON"} { - t.Setenv(key, "") - t.Setenv(prefix+key, "") - } - - localDestination, err := configuredTelemetryDestination(true) - require.NoError(t, err) - require.False(t, localDestination.Available()) - require.NotEmpty(t, localDestination.AbsentReason) - _, err = configuredTelemetryDestination(false) - require.ErrorContains(t, err, "TELEMETRY_STATE is not set") - - // Ambient variables a shell or a pod may carry are not the group. - t.Setenv("TELEMETRY_STATE", "available") - t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://ambient.example:4318") - _, err = configuredTelemetryDestination(false) - require.ErrorContains(t, err, "TELEMETRY_STATE is not set") - t.Setenv("TELEMETRY_STATE", "") - t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "") - - t.Setenv(prefix+"TELEMETRY_STATE", "available") - t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", collector) - destination, err := configuredTelemetryDestination(false) - require.NoError(t, err) - require.Equal(t, "otel-collector.otel-collector.svc.cluster.local:4317", destination.Endpoint) - require.True(t, destination.Insecure) - - t.Setenv(prefix+"TELEMETRY_STATE", "absent") - t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", "") - t.Setenv(prefix+"TELEMETRY_ABSENT_REASON", "a local run has no cell collector") - destination, err = configuredTelemetryDestination(false) - require.NoError(t, err) - require.False(t, destination.Available()) - require.Equal(t, "a local run has no cell collector", destination.AbsentReason) -} - -// Through the SDK's lookup, with the keys exactly as a deployed cell delivers -// them after the layers merge: the cell's state and endpoint, and a reason a -// lower layer left behind. -func TestConfiguredTelemetryDestinationStateDecidesAcrossLayers(t *testing.T) { - const prefix = "CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__" - t.Setenv(prefix+"TELEMETRY_STATE", "available") - t.Setenv(prefix+"OTEL_EXPORTER_OTLP_ENDPOINT", collector) - t.Setenv(prefix+"TELEMETRY_ABSENT_REASON", "A local run has no cell collector.") - - destination, err := configuredTelemetryDestination(false) - require.NoError(t, err) - require.True(t, destination.Available()) - require.Equal(t, collector, destination.URL) - require.NotEmpty(t, destination.IgnoredNotice()) -} - -// service.name is never a literal typed here: the environment wins, then the -// service's own Codefly identity. -func TestTelemetryResourceServiceName(t *testing.T) { - resolve := func(t *testing.T, identity string) string { - t.Helper() - res, err := telemetryResource(t.Context(), identity) - require.NoError(t, err) - return telemetryServiceName(res) - } - - t.Run("falls back to the Codefly service identity", func(t *testing.T) { - t.Setenv("OTEL_SERVICE_NAME", "") - t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") - require.Equal(t, "saas-starter/accounts", resolve(t, "saas-starter/accounts")) - }) - t.Run("OTEL_SERVICE_NAME wins over the identity", func(t *testing.T) { - t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") - t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") - require.Equal(t, "named-by-the-platform", resolve(t, "saas-starter/accounts")) - }) - t.Run("OTEL_RESOURCE_ATTRIBUTES can set it", func(t *testing.T) { - t.Setenv("OTEL_SERVICE_NAME", "") - t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.name=attributed-by-the-platform,deployment.environment=test") - require.Equal(t, "attributed-by-the-platform", resolve(t, "saas-starter/accounts")) - }) - t.Run("without either, the SDK default rather than a literal", func(t *testing.T) { - t.Setenv("OTEL_SERVICE_NAME", "") - t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") - require.True(t, strings.HasPrefix(resolve(t, ""), "unknown_service"), resolve(t, "")) - }) -} - -// The identity is the one the SDK built its provider with, the same one wool -// stamps on this process's log lines — read from the provider, not from the -// runtime's environment carrier. -func TestCodeflyServiceNameIsTheSDKProviderIdentity(t *testing.T) { - carrying := func(unique string) context.Context { - return wool.New(t.Context(), &wool.Resource{Kind: "service", Unique: unique}).Inject(t.Context()) - } - require.Equal(t, "saas-starter/accounts", codeflyServiceName(carrying("saas-starter/accounts"))) - require.Equal(t, "accounts", codeflyServiceName(carrying("/accounts")), "an identity with no module") - require.Empty(t, codeflyServiceName(carrying("/")), "an identity with neither") - require.Empty(t, codeflyServiceName(t.Context()), "no provider: the SDK was not initialised") -} - -// wool's tracer speaks plaintext only. An https:// endpoint is refused rather -// than dialed in the clear and reported as TLS. -func TestEnableTracingRefusesATransportWoolCannotDial(t *testing.T) { - provider, err := enableTracing(t.Context(), - telemetryDestination{Endpoint: "collector.example:4317", Insecure: false}, false) - require.ErrorIs(t, err, errWoolCannotDialTLS) - require.Nil(t, provider) -} - -func TestEnableTracingRegistersOnlyWhatTheDestinationCalls(t *testing.T) { - restore := func(t *testing.T) { - t.Helper() - previous := otel.GetTracerProvider() - t.Cleanup(func() { - wool.RegisterTelemetry(nil) - otel.SetTracerProvider(previous) - }) - } - absent := telemetryDestination{AbsentReason: "a local run has no cell collector"} - - t.Run("absent outside a local run registers no tracer", func(t *testing.T) { - restore(t) - provider, err := enableTracing(t.Context(), absent, false) - require.NoError(t, err) - require.Nil(t, provider) - require.False(t, wool.TelemetryEnabled()) - }) - t.Run("absent in a local run uses wool's stdout tracer", func(t *testing.T) { - restore(t) - provider, err := enableTracing(t.Context(), absent, true) - require.NoError(t, err) - require.NotNil(t, provider) - require.True(t, wool.TelemetryEnabled()) - shutdown(t, provider) - }) - t.Run("available over http exports spans to the collector, named by the environment", func(t *testing.T) { - restore(t) - t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") - t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") - endpoint, capture := startTraceCapture(t) - provider, err := enableTracing(t.Context(), telemetryDestination{Endpoint: endpoint, Insecure: true}, false) - require.NoError(t, err) - require.NotNil(t, provider) - require.True(t, wool.TelemetryEnabled()) - - _, span := otel.Tracer("test").Start(t.Context(), "operation") - span.End() - shutdown(t, provider) // flushes the batch - - select { - case request := <-capture.requests: - var name string - for _, resourceSpans := range request.GetResourceSpans() { - for _, attribute := range resourceSpans.GetResource().GetAttributes() { - if attribute.GetKey() == "service.name" { - name = attribute.GetValue().GetStringValue() - } - } - } - require.Equal(t, "named-by-the-platform", name) - case <-time.After(5 * time.Second): - t.Fatal("timed out waiting for OTLP spans") - } - }) -} - -type traceCapture struct { - collectortracev1.UnimplementedTraceServiceServer - requests chan *collectortracev1.ExportTraceServiceRequest -} - -func (c *traceCapture) Export( - _ context.Context, - request *collectortracev1.ExportTraceServiceRequest, -) (*collectortracev1.ExportTraceServiceResponse, error) { - c.requests <- request - return &collectortracev1.ExportTraceServiceResponse{}, nil -} - -// startTraceCapture serves an in-process OTLP/gRPC trace receiver on loopback. -func startTraceCapture(t *testing.T) (string, *traceCapture) { - t.Helper() - listener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - capture := &traceCapture{requests: make(chan *collectortracev1.ExportTraceServiceRequest, 4)} - server := grpc.NewServer() - collectortracev1.RegisterTraceServiceServer(server, capture) - go func() { _ = server.Serve(listener) }() - t.Cleanup(func() { - server.Stop() - _ = listener.Close() - }) - return listener.Addr().String(), capture -} - -func shutdown(t *testing.T, provider interface{ Shutdown(context.Context) error }) { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - require.NoError(t, provider.Shutdown(ctx)) -} diff --git a/module/services/auth-gateway/code/telemetry_metrics.go b/module/services/auth-gateway/code/telemetry_metrics.go index 114e1e107..b30c63352 100644 --- a/module/services/auth-gateway/code/telemetry_metrics.go +++ b/module/services/auth-gateway/code/telemetry_metrics.go @@ -2,7 +2,6 @@ package main import ( "context" - "errors" "net/http" runtimemetrics "runtime/metrics" "time" @@ -16,17 +15,21 @@ import ( ) // otelMetrics owns the process's MeterProvider. Metrics leave by one path only: -// an OTLP push to the cell's collector, which is the record for traces and -// metrics alike. There is no scrape endpoint to mount, expose or exempt. +// an OTLP push to the collector OTEL_EXPORTER_OTLP_ENDPOINT names, which is the +// record for traces and metrics alike. There is no scrape endpoint to mount, +// expose or exempt. type otelMetrics struct { provider *metric.MeterProvider } -func enableOTELMetrics(ctx context.Context, destination telemetryDestination) (*otelMetrics, error) { - if destination.URL == "" { - // WithEndpointURL("") would fall back to the exporter's own default, - // localhost:4317: a process that exports to nowhere and reports success. - return nil, errors.New("telemetry: no collector URL to export metrics to") +// enableOTELMetrics starts the process's OTLP metrics push to the collector the +// environment names. It is only for a process that was given one: with no +// OTEL_EXPORTER_OTLP_ENDPOINT it refuses rather than build an exporter, because +// an otlpmetricgrpc exporter with nothing configured dials its own default, +// localhost:4317 over TLS, and a process that exports to nowhere reports success. +func enableOTELMetrics(ctx context.Context) (*otelMetrics, error) { + if err := requireMetricsEndpoint(); err != nil { + return nil, err } // The resource comes before the exporter, so a failure here leaves nothing // to shut down: an exporter holds a client connection that only Shutdown @@ -35,9 +38,10 @@ func enableOTELMetrics(ctx context.Context, destination telemetryDestination) (* if err != nil { return nil, err } - // The exporter reads the transport off the URL's scheme itself: http:// is - // plaintext on the wire, because the mesh supplies mTLS, and https:// is TLS. - exporter, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithEndpointURL(destination.URL)) + // The exporter reads the collector from OTEL_EXPORTER_OTLP_ENDPOINT itself and + // takes the transport off the URL's scheme: http:// is plaintext on the wire, + // because the mesh supplies mTLS, and https:// is TLS. + exporter, err := otlpmetricgrpc.New(ctx) if err != nil { return nil, err } diff --git a/module/services/auth-gateway/code/telemetry_metrics_test.go b/module/services/auth-gateway/code/telemetry_metrics_test.go index 3f66755eb..5269bf211 100644 --- a/module/services/auth-gateway/code/telemetry_metrics_test.go +++ b/module/services/auth-gateway/code/telemetry_metrics_test.go @@ -41,7 +41,8 @@ func TestAuthGatewayTelemetryExportsGatewayAndGRPCRED(t *testing.T) { t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.instance.id=auth-test-instance") t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") previousMeterProvider := otel.GetMeterProvider() - metrics, err := enableOTELMetrics(t.Context(), availableAt(t, "http", endpoint)) + collectorAt(t, "http", endpoint) + metrics, err := enableOTELMetrics(t.Context()) require.NoError(t, err) require.NotNil(t, metrics) t.Cleanup(func() { @@ -134,26 +135,14 @@ func authFloat64SumValue(request *collectormetricsv1.ExportMetricsServiceRequest return value } -// availableAt resolves the destination the way the process does, from the -// endpoint the platform would deliver, so these tests hand the exporter the URL -// it really receives rather than a hand-built struct. -func availableAt(t *testing.T, scheme, hostPort string) telemetryDestination { - t.Helper() - destination, err := resolveTelemetryDestination(false, group(map[string]string{ - "TELEMETRY_STATE": "available", - "OTEL_EXPORTER_OTLP_ENDPOINT": scheme + "://" + hostPort, - })) - require.NoError(t, err) - return destination -} - // The exporter reads its transport off the URL's scheme, so an https:// URL is // TLS and cannot talk to a plaintext receiver. A constant WithInsecure() would // have let it. func TestEnableOTELMetricsDerivesTransportFromTheURLScheme(t *testing.T) { endpoint, capture := startAuthMetricCapture(t) previousMeterProvider := otel.GetMeterProvider() - metrics, err := enableOTELMetrics(t.Context(), availableAt(t, "https", endpoint)) + collectorAt(t, "https", endpoint) + metrics, err := enableOTELMetrics(t.Context()) require.NoError(t, err) require.NotNil(t, metrics) t.Cleanup(func() { @@ -173,12 +162,33 @@ func TestEnableOTELMetricsDerivesTransportFromTheURLScheme(t *testing.T) { } } -// A destination with no collector URL is refused. WithEndpointURL("") would -// otherwise fall back to the exporter's own default, localhost:4317. -func TestEnableOTELMetricsRefusesADestinationWithoutAURL(t *testing.T) { - metrics, err := enableOTELMetrics(t.Context(), telemetryDestination{AbsentReason: "no collector"}) - require.ErrorContains(t, err, "no collector URL") - require.Nil(t, metrics) +// A process with no collector configured builds no exporter: an +// otlpmetricgrpc exporter with nothing configured dials its own default, +// localhost:4317, so the call refuses instead. +func TestEnableOTELMetricsRefusesWithoutAnEndpoint(t *testing.T) { + for name, value := range map[string]string{"unset": "", "blank": " "} { + t.Run(name, func(t *testing.T) { + isolateOTLPEnvironment(t) + t.Setenv(otlpEndpointVariable, value) + metrics, err := enableOTELMetrics(t.Context()) + require.ErrorContains(t, err, "no "+otlpEndpointVariable) + require.Nil(t, metrics) + }) + } +} + +// The exporter reads the variable only as an http:// or https:// URL. Any other +// form keeps its default, localhost:4317, without a word, so it is refused here, +// and the refusal does not echo the value, which may carry credentials. +func TestEnableOTELMetricsRefusesAnEndpointTheExporterCannotRead(t *testing.T) { + for _, value := range []string{"collector.example:4317", "ftp://collector.example:4317", "://bad", "secret-user:secret-pass@collector.example:4317"} { + isolateOTLPEnvironment(t) + t.Setenv(otlpEndpointVariable, value) + metrics, err := enableOTELMetrics(t.Context()) + require.ErrorContains(t, err, "http:// or https://", value) + require.NotContains(t, err.Error(), "secret-pass") + require.Nil(t, metrics) + } } // A resource that cannot be built fails the whole call, and the call builds it @@ -188,7 +198,8 @@ func TestEnableOTELMetricsFailsWhenTheResourceCannotBeBuilt(t *testing.T) { t.Setenv("OTEL_SERVICE_NAME", "") t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "an-attribute-with-no-value") endpoint, _ := startAuthMetricCapture(t) - metrics, err := enableOTELMetrics(t.Context(), availableAt(t, "http", endpoint)) + collectorAt(t, "http", endpoint) + metrics, err := enableOTELMetrics(t.Context()) require.Error(t, err) require.Nil(t, metrics) } diff --git a/module/services/auth-gateway/code/telemetry_test.go b/module/services/auth-gateway/code/telemetry_test.go new file mode 100644 index 000000000..07eb32cf3 --- /dev/null +++ b/module/services/auth-gateway/code/telemetry_test.go @@ -0,0 +1,273 @@ +package main + +import ( + "context" + "net" + "os" + "strings" + "testing" + "time" + + "github.com/codefly-dev/core/wool" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + collectortracev1 "go.opentelemetry.io/proto/otlp/collector/trace/v1" + "google.golang.org/grpc" +) + +// isolateOTLPEnvironment clears every OTLP variable the exporters read, so what a +// test sets is the whole configuration and nothing leaks in from the machine +// running it. +func isolateOTLPEnvironment(t *testing.T) { + t.Helper() + for _, name := range []string{ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_CERTIFICATE", + "OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE", + "OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE", + "OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE", + "OTEL_EXPORTER_OTLP_CLIENT_KEY", + "OTEL_EXPORTER_OTLP_INSECURE", + "OTEL_EXPORTER_OTLP_HEADERS", + } { + t.Setenv(name, "") + } +} + +// collectorAt is the platform's whole delivery: the one variable, set to the +// collector's address. +func collectorAt(t *testing.T, scheme, hostPort string) { + t.Helper() + isolateOTLPEnvironment(t) + t.Setenv(otlpEndpointVariable, scheme+"://"+hostPort) +} + +// shippedObservabilityProfile is what the module's own `observability` profile +// contributes to the group, read from the file it ships. +func shippedObservabilityProfile(t *testing.T) map[string]string { + t.Helper() + contents, err := os.ReadFile("../../../configurations/local/observability.env") + require.NoError(t, err) + values := map[string]string{} + for _, line := range strings.Split(string(contents), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + require.True(t, ok) + values[key] = value + } + return values +} + +// Module defaults also reach deployed cells, so the profile names no collector: +// the group exists (a composing workspace inherits a group from the key it +// assigns), and the one variable it assigns is empty, which is no endpoint. +func TestShippedObservabilityProfileNamesNoCollector(t *testing.T) { + require.Equal(t, map[string]string{otlpEndpointVariable: ""}, shippedObservabilityProfile(t)) +} + +func TestOTLPEndpointConfigured(t *testing.T) { + isolateOTLPEnvironment(t) + require.False(t, otlpEndpointConfigured(), "unset") + t.Setenv(otlpEndpointVariable, " ") + require.False(t, otlpEndpointConfigured(), "a blank value is no endpoint: the metrics exporter trims it to unset") + t.Setenv(otlpEndpointVariable, "http://otel-collector.otel-collector.svc.cluster.local:4317") + require.True(t, otlpEndpointConfigured()) +} + +// service.name is never a literal typed here: the environment wins, then the +// service's own Codefly identity. +func TestTelemetryResourceServiceName(t *testing.T) { + resolve := func(t *testing.T, identity string) string { + t.Helper() + res, err := telemetryResource(t.Context(), identity) + require.NoError(t, err) + return telemetryServiceName(res) + } + + t.Run("falls back to the Codefly service identity", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + require.Equal(t, "saas-starter/auth-gateway", resolve(t, "saas-starter/auth-gateway")) + }) + t.Run("OTEL_SERVICE_NAME wins over the identity", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + require.Equal(t, "named-by-the-platform", resolve(t, "saas-starter/auth-gateway")) + }) + t.Run("OTEL_RESOURCE_ATTRIBUTES can set it", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.name=attributed-by-the-platform,deployment.environment=test") + require.Equal(t, "attributed-by-the-platform", resolve(t, "saas-starter/auth-gateway")) + }) + t.Run("without either, the SDK default rather than a literal", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + require.True(t, strings.HasPrefix(resolve(t, ""), "unknown_service"), resolve(t, "")) + }) +} + +// The identity is the one the SDK built its provider with, the same one wool +// stamps on this process's log lines — read from the provider, not from the +// runtime's environment carrier. +func TestCodeflyServiceNameIsTheSDKProviderIdentity(t *testing.T) { + carrying := func(unique string) context.Context { + return wool.New(t.Context(), &wool.Resource{Kind: "service", Unique: unique}).Inject(t.Context()) + } + require.Equal(t, "saas-starter/auth-gateway", codeflyServiceName(carrying("saas-starter/auth-gateway"))) + require.Equal(t, "auth-gateway", codeflyServiceName(carrying("/auth-gateway")), "an identity with no module") + require.Empty(t, codeflyServiceName(carrying("/")), "an identity with neither") + require.Empty(t, codeflyServiceName(t.Context()), "no provider: the SDK was not initialised") +} + +func restoreTracing(t *testing.T) { + t.Helper() + previous := otel.GetTracerProvider() + t.Cleanup(func() { + wool.RegisterTelemetry(nil) + otel.SetTracerProvider(previous) + }) +} + +// With no endpoint delivered the process registers no OTLP exporter: nothing +// is exported, and nothing dials a default address. +func TestEnableTracingWithoutAnEndpointRegistersNothingOutsideALocalRun(t *testing.T) { + restoreTracing(t) + isolateOTLPEnvironment(t) + provider, err := enableTracing(t.Context(), false) + require.NoError(t, err) + require.Nil(t, provider) + require.False(t, wool.TelemetryEnabled()) +} + +// A local run with no endpoint keeps wool's own stdout tracer, which exports +// nothing over OTLP. +func TestEnableTracingWithoutAnEndpointUsesWoolsStdoutTracerInALocalRun(t *testing.T) { + restoreTracing(t) + isolateOTLPEnvironment(t) + provider, err := enableTracing(t.Context(), true) + require.NoError(t, err) + require.NotNil(t, provider) + require.True(t, wool.TelemetryEnabled()) + shutdown(t, provider) +} + +// The endpoint decides, not where the process runs: a pod deployed into an +// environment named `local` that is handed a collector exports to it, rather +// than falling back to the stdout tracer. +func TestEnableTracingExportsToTheDeliveredEndpointWhetherOrNotTheRunIsLocal(t *testing.T) { + for name, local := range map[string]bool{"deployed": false, "local environment": true} { + t.Run(name, func(t *testing.T) { + restoreTracing(t) + t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + endpoint, capture := startTraceCapture(t) + collectorAt(t, "http", endpoint) + provider, err := enableTracing(t.Context(), local) + require.NoError(t, err) + require.NotNil(t, provider) + require.True(t, wool.TelemetryEnabled()) + + _, span := otel.Tracer("test").Start(t.Context(), "operation") + span.End() + shutdown(t, provider) // flushes the batch + + select { + case request := <-capture.requests: + var name string + for _, resourceSpans := range request.GetResourceSpans() { + for _, attribute := range resourceSpans.GetResource().GetAttributes() { + if attribute.GetKey() == "service.name" { + name = attribute.GetValue().GetStringValue() + } + } + } + require.Equal(t, "named-by-the-platform", name) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for OTLP spans") + } + }) + } +} + +// core's wool/otel owns what an endpoint may be, and a value it refuses stops +// the process with its reason and registers nothing — never a tracer pointed at a +// default address. +func TestEnableTracingReturnsWhatWoolRefusesAndRegistersNothing(t *testing.T) { + for name, value := range map[string]string{ + "no port": "http://otel-collector.otel-collector.svc.cluster.local", + "userinfo": "http://user:secret-pass@collector.example:4317", + } { + t.Run(name, func(t *testing.T) { + restoreTracing(t) + isolateOTLPEnvironment(t) + t.Setenv(otlpEndpointVariable, value) + provider, err := enableTracing(t.Context(), false) + require.Error(t, err) + require.NotContains(t, err.Error(), "secret-pass", "the refusal never echoes the URL") + require.Nil(t, provider) + require.False(t, wool.TelemetryEnabled()) + }) + } +} + +// wool takes a value that is not an http:// or https:// URL for a bare +// host:port, the shape it accepted before it read URLs, and dials it in +// plaintext. The metrics exporter reads only a URL and, given anything else, +// keeps its default, localhost:4317. So the tracer would accept what the +// metrics exporter silently misreads, which is why the metrics call refuses it: +// both signals follow the one variable, and neither is pointed at a default. +func TestAValueWoolAcceptsAsHostPortIsRefusedByTheMetricsExporterGuard(t *testing.T) { + restoreTracing(t) + isolateOTLPEnvironment(t) + t.Setenv(otlpEndpointVariable, "collector.example:4317") + + provider, err := enableTracing(t.Context(), false) + require.NoError(t, err) + require.NotNil(t, provider) + shutdown(t, provider) + + metrics, err := enableOTELMetrics(t.Context()) + require.ErrorContains(t, err, "http:// or https://") + require.Nil(t, metrics) +} + +type traceCapture struct { + collectortracev1.UnimplementedTraceServiceServer + requests chan *collectortracev1.ExportTraceServiceRequest +} + +func (c *traceCapture) Export( + _ context.Context, + request *collectortracev1.ExportTraceServiceRequest, +) (*collectortracev1.ExportTraceServiceResponse, error) { + c.requests <- request + return &collectortracev1.ExportTraceServiceResponse{}, nil +} + +// startTraceCapture serves an in-process OTLP/gRPC trace receiver on loopback. +func startTraceCapture(t *testing.T) (string, *traceCapture) { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + capture := &traceCapture{requests: make(chan *collectortracev1.ExportTraceServiceRequest, 4)} + server := grpc.NewServer() + collectortracev1.RegisterTraceServiceServer(server, capture) + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + return listener.Addr().String(), capture +} + +func shutdown(t *testing.T, provider interface{ Shutdown(context.Context) error }) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, provider.Shutdown(ctx)) +} diff --git a/module/tools/telemetry_destination_mirror_test.go b/module/tools/telemetry_destination_mirror_test.go deleted file mode 100644 index e7310335e..000000000 --- a/module/tools/telemetry_destination_mirror_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package tools - -import ( - "os" - "path/filepath" - "testing" -) - -// telemetryDestinationCopies are source files that exist in both Go services, -// keyed by the copy and valued by the file it must match. -// -// accounts and auth-gateway both resolve where the cell's collector is from the -// `observability` configuration group: the same three keys, the same three -// outcomes, the same refusal to start on anything else. They are separate Go -// modules with no go.work, so neither can import the other, and a shared module -// would be a third thing to version for one small decision. The copy is the -// price; this test keeps the price from being paid twice, because two producers -// that read the group differently would let one service export while the other -// refuses to start on the same configuration. -var telemetryDestinationCopies = map[string]string{ - "services/auth-gateway/code/telemetry_destination.go": "services/accounts/code/telemetry_destination.go", -} - -func TestTelemetryDestinationCopiesMatchTheirSource(t *testing.T) { - moduleDir := findModuleDir(t) - for copyPath, sourcePath := range telemetryDestinationCopies { - copyBody, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(copyPath))) - if err != nil { - t.Fatalf("read telemetry destination copy %s: %v", copyPath, err) - } - sourceBody, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(sourcePath))) - if err != nil { - t.Fatalf("read telemetry destination source %s: %v", sourcePath, err) - } - if string(copyBody) != string(sourceBody) { - t.Errorf("%s has diverged from %s; change the source and copy it over the other so both services resolve the collector the same way", - copyPath, sourcePath) - } - } -} From c46a4abb8b22a012fbf27394d0486315c7507b4e Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:38:35 +0000 Subject: [PATCH 14/20] fix(build): consume released Go 1.27.3 service agent --- .gitattributes | 5 +++++ .github/workflows/ci.yml | 20 +++++++++---------- .../runnable-package.json | 2 +- .../runnable-package.json | 2 +- .../ReconcileSource/runnable-package.json | 2 +- module/contracts/runnables/index.json | 6 +++--- module/services/accounts/builder/Dockerfile | 2 +- module/services/accounts/service.codefly.yaml | 4 ++-- .../services/auth-gateway/builder/Dockerfile | 2 +- .../auth-gateway/service.codefly.yaml | 2 +- module/services/policy-log/builder/Dockerfile | 2 +- .../services/policy-log/service.codefly.yaml | 2 +- scripts/ci/build-images.json | 2 +- 13 files changed, 29 insertions(+), 24 deletions(-) diff --git a/.gitattributes b/.gitattributes index 9231aaa03..522c2b612 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,3 +7,8 @@ module/services/frontend/code/src/gen/** whitespace=-blank-at-eof module/deployment/generated/** linguist-generated=true module/contracts/runnables/** linguist-generated=true module/services/telemetry/code/pkg/gen/** linguist-generated=true + +# Build recipes emitted by the pinned Go service agent. +module/services/accounts/builder/** linguist-generated=true +module/services/auth-gateway/builder/** linguist-generated=true +module/services/policy-log/builder/** linguist-generated=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a198f35b..63f5d2f1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,7 +89,7 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 if: steps.migration-reference.outputs.replay == 'true' with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: module/services/store/code/go.sum - name: Verify migration upgrade and clean install on PostgreSQL if: steps.migration-reference.outputs.replay == 'true' @@ -246,7 +246,7 @@ jobs: fi - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: | go.sum module/tools/go.sum @@ -344,7 +344,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: module/services/accounts/code/go.sum # The suite needs a native PostgreSQL it can initdb and own, not a service # container: it provisions its own logins, roles and grants, and widens one @@ -392,7 +392,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: | go.sum module/tools/go.sum @@ -468,7 +468,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: module/tools/go.sum - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -591,7 +591,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" - name: Reject ineffective image proposals before service CI env: CODEFLY_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} @@ -723,7 +723,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: | go.sum module/tools/go.sum @@ -948,7 +948,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: | go.sum module/tools/go.sum @@ -1028,7 +1028,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: | go.sum module/tools/go.sum @@ -1181,7 +1181,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: module/tools/go.sum # The release record runs on the same Node every gate is tested against, # rather than whichever version the runner image happens to ship. diff --git a/module/contracts/runnables/accounts/connect/InvokeSourceOperation/runnable-package.json b/module/contracts/runnables/accounts/connect/InvokeSourceOperation/runnable-package.json index dc8911c35..1cd13f306 100644 --- a/module/contracts/runnables/accounts/connect/InvokeSourceOperation/runnable-package.json +++ b/module/contracts/runnables/accounts/connect/InvokeSourceOperation/runnable-package.json @@ -1 +1 @@ -{"agent":{"kind":"SERVICE","name":"go-grpc","publisher":"codefly.dev","version":"0.1.54"},"contract":{"input":{"fields":[{"name":"org_id","type":"STRING"},{"name":"source_id","type":"STRING"},{"name":"operation","type":"STRING"},{"name":"input_json","type":"STRING"},{"name":"effect_id","type":"STRING"}]},"output":{"fields":[{"name":"output_json","type":"STRING"},{"fields":[{"name":"effect_id","type":"STRING"},{"name":"committed_at","type":"STRING"},{"name":"status","type":"STRING"},{"name":"provider_status","type":"INTEGER"},{"name":"output_json","type":"STRING"}],"name":"receipt","optional":true,"type":"OBJECT"}]},"protocol":"codefly.runnable.service/v1"},"digest":"c814b9dee9975938f0b905bede5ade004ca6f34fc1be10e8188d8962eb4dc700","execution":{"cancellation":"CANCELLATION_NONE","completion":"COMPLETION_CALL","facilities":[{"kind":"SERVICE"}],"max_input_bytes":"65536","max_output_bytes":"1048576","recovery":"RECOVERY_RECEIPT","timeout":"600s"},"identity":{"module":"saas-starter","name":"accounts-connect-invoke-source-operation","version":"0.2.0","workspace":"saas-starter-dev"},"schema":"codefly.runnable-package/v1","service_operations":[{"adaptation":"ADAPTATION_BOUNDED_JSON_V1","endpoint":"connect","input_message":"saas.accounts.v1.InvokeSourceOperationRequest","module":"saas-starter","name":"accounts","operation":"/saas.accounts.v1.DatasourceService/InvokeSourceOperation","output_message":"saas.accounts.v1.InvokeSourceOperationResponse"}]} +{"agent":{"kind":"SERVICE","name":"go-grpc","publisher":"codefly.dev","version":"0.1.58"},"contract":{"input":{"fields":[{"name":"org_id","type":"STRING"},{"name":"source_id","type":"STRING"},{"name":"operation","type":"STRING"},{"name":"input_json","type":"STRING"},{"name":"effect_id","type":"STRING"}]},"output":{"fields":[{"name":"output_json","type":"STRING"},{"fields":[{"name":"effect_id","type":"STRING"},{"name":"committed_at","type":"STRING"},{"name":"status","type":"STRING"},{"name":"provider_status","type":"INTEGER"},{"name":"output_json","type":"STRING"}],"name":"receipt","optional":true,"type":"OBJECT"}]},"protocol":"codefly.runnable.service/v1"},"digest":"2b15d5c58084f594b5dfddab8afda44b9030e7a32b5cdbce6d9e8c8be621e883","execution":{"cancellation":"CANCELLATION_NONE","completion":"COMPLETION_CALL","facilities":[{"kind":"SERVICE"}],"max_input_bytes":"65536","max_output_bytes":"1048576","recovery":"RECOVERY_RECEIPT","timeout":"600s"},"identity":{"module":"saas-starter","name":"accounts-connect-invoke-source-operation","version":"0.2.0","workspace":"saas-starter-dev"},"schema":"codefly.runnable-package/v1","service_operations":[{"adaptation":"ADAPTATION_BOUNDED_JSON_V1","endpoint":"connect","input_message":"saas.accounts.v1.InvokeSourceOperationRequest","module":"saas-starter","name":"accounts","operation":"/saas.accounts.v1.DatasourceService/InvokeSourceOperation","output_message":"saas.accounts.v1.InvokeSourceOperationResponse"}]} diff --git a/module/contracts/runnables/accounts/connect/PruneSourceOperationReceipts/runnable-package.json b/module/contracts/runnables/accounts/connect/PruneSourceOperationReceipts/runnable-package.json index f73dfc539..6793aa6fa 100644 --- a/module/contracts/runnables/accounts/connect/PruneSourceOperationReceipts/runnable-package.json +++ b/module/contracts/runnables/accounts/connect/PruneSourceOperationReceipts/runnable-package.json @@ -1 +1 @@ -{"agent":{"kind":"SERVICE","name":"go-grpc","publisher":"codefly.dev","version":"0.1.54"},"contract":{"input":{"fields":[{"name":"org_id","type":"STRING"},{"name":"source_id","type":"STRING"},{"name":"effect_id","type":"STRING"}]},"output":{"fields":[{"name":"effect_id","type":"STRING"},{"name":"receipts","type":"INTEGER"},{"name":"status","type":"STRING"}]},"protocol":"codefly.runnable.service/v1"},"digest":"210889b481b442f6c497a245ebffea24ac3b85bdb0adffb8d061cc7236b6b39e","execution":{"cancellation":"CANCELLATION_NONE","completion":"COMPLETION_CALL","facilities":[{"kind":"SERVICE"}],"max_input_bytes":"4096","max_output_bytes":"4096","recovery":"RECOVERY_RECEIPT","timeout":"600s"},"identity":{"module":"saas-starter","name":"accounts-connect-prune-source-operation-receipts","version":"0.2.0","workspace":"saas-starter-dev"},"schema":"codefly.runnable-package/v1","service_operations":[{"adaptation":"ADAPTATION_BOUNDED_JSON_V1","endpoint":"connect","input_message":"saas.accounts.v1.PruneSourceOperationReceiptsRequest","module":"saas-starter","name":"accounts","operation":"/saas.accounts.v1.DatasourceService/PruneSourceOperationReceipts","output_message":"saas.accounts.v1.PruneSourceOperationReceiptsResponse"}]} +{"agent":{"kind":"SERVICE","name":"go-grpc","publisher":"codefly.dev","version":"0.1.58"},"contract":{"input":{"fields":[{"name":"org_id","type":"STRING"},{"name":"source_id","type":"STRING"},{"name":"effect_id","type":"STRING"}]},"output":{"fields":[{"name":"effect_id","type":"STRING"},{"name":"receipts","type":"INTEGER"},{"name":"status","type":"STRING"}]},"protocol":"codefly.runnable.service/v1"},"digest":"a843f385794a3bc4fa1d864b221e1bec5482c45180d6b644514d4ccac0cd9851","execution":{"cancellation":"CANCELLATION_NONE","completion":"COMPLETION_CALL","facilities":[{"kind":"SERVICE"}],"max_input_bytes":"4096","max_output_bytes":"4096","recovery":"RECOVERY_RECEIPT","timeout":"600s"},"identity":{"module":"saas-starter","name":"accounts-connect-prune-source-operation-receipts","version":"0.2.0","workspace":"saas-starter-dev"},"schema":"codefly.runnable-package/v1","service_operations":[{"adaptation":"ADAPTATION_BOUNDED_JSON_V1","endpoint":"connect","input_message":"saas.accounts.v1.PruneSourceOperationReceiptsRequest","module":"saas-starter","name":"accounts","operation":"/saas.accounts.v1.DatasourceService/PruneSourceOperationReceipts","output_message":"saas.accounts.v1.PruneSourceOperationReceiptsResponse"}]} diff --git a/module/contracts/runnables/accounts/connect/ReconcileSource/runnable-package.json b/module/contracts/runnables/accounts/connect/ReconcileSource/runnable-package.json index 2323ddc58..f357d98f9 100644 --- a/module/contracts/runnables/accounts/connect/ReconcileSource/runnable-package.json +++ b/module/contracts/runnables/accounts/connect/ReconcileSource/runnable-package.json @@ -1 +1 @@ -{"agent":{"kind":"SERVICE","name":"go-grpc","publisher":"codefly.dev","version":"0.1.54"},"contract":{"input":{"fields":[{"name":"org_id","type":"STRING"},{"name":"source_id","type":"STRING"},{"name":"effect_id","type":"STRING"}]},"output":{"fields":[{"name":"effect_id","type":"STRING"},{"name":"status","type":"STRING"},{"name":"job_id","type":"STRING"}]},"protocol":"codefly.runnable.service/v1"},"digest":"be1042c57da41fefb9931b166292704af8ad46f0894a57d32f9f46c88d871313","execution":{"cancellation":"CANCELLATION_NONE","completion":"COMPLETION_CALL","facilities":[{"kind":"SERVICE"}],"max_input_bytes":"4096","max_output_bytes":"4096","recovery":"RECOVERY_RECEIPT","timeout":"600s"},"identity":{"module":"saas-starter","name":"accounts-connect-reconcile-source","version":"0.2.0","workspace":"saas-starter-dev"},"schema":"codefly.runnable-package/v1","service_operations":[{"adaptation":"ADAPTATION_BOUNDED_JSON_V1","endpoint":"connect","input_message":"saas.accounts.v1.ReconcileSourceRequest","module":"saas-starter","name":"accounts","operation":"/saas.accounts.v1.DatasourceService/ReconcileSource","output_message":"saas.accounts.v1.ReconcileSourceResponse"}]} +{"agent":{"kind":"SERVICE","name":"go-grpc","publisher":"codefly.dev","version":"0.1.58"},"contract":{"input":{"fields":[{"name":"org_id","type":"STRING"},{"name":"source_id","type":"STRING"},{"name":"effect_id","type":"STRING"}]},"output":{"fields":[{"name":"effect_id","type":"STRING"},{"name":"status","type":"STRING"},{"name":"job_id","type":"STRING"}]},"protocol":"codefly.runnable.service/v1"},"digest":"5782459eaa3ad22b429857eea3b0d50805eb73a2cd8a7c364a6c77d82751f13e","execution":{"cancellation":"CANCELLATION_NONE","completion":"COMPLETION_CALL","facilities":[{"kind":"SERVICE"}],"max_input_bytes":"4096","max_output_bytes":"4096","recovery":"RECOVERY_RECEIPT","timeout":"600s"},"identity":{"module":"saas-starter","name":"accounts-connect-reconcile-source","version":"0.2.0","workspace":"saas-starter-dev"},"schema":"codefly.runnable-package/v1","service_operations":[{"adaptation":"ADAPTATION_BOUNDED_JSON_V1","endpoint":"connect","input_message":"saas.accounts.v1.ReconcileSourceRequest","module":"saas-starter","name":"accounts","operation":"/saas.accounts.v1.DatasourceService/ReconcileSource","output_message":"saas.accounts.v1.ReconcileSourceResponse"}]} diff --git a/module/contracts/runnables/index.json b/module/contracts/runnables/index.json index fb318a0c7..96b147583 100644 --- a/module/contracts/runnables/index.json +++ b/module/contracts/runnables/index.json @@ -6,7 +6,7 @@ { "name": "accounts-connect-invoke-source-operation", "version": "0.2.0", - "digest": "c814b9dee9975938f0b905bede5ade004ca6f34fc1be10e8188d8962eb4dc700", + "digest": "2b15d5c58084f594b5dfddab8afda44b9030e7a32b5cdbce6d9e8c8be621e883", "service": "accounts", "endpoint": "connect", "method": "/saas.accounts.v1.DatasourceService/InvokeSourceOperation", @@ -17,7 +17,7 @@ { "name": "accounts-connect-prune-source-operation-receipts", "version": "0.2.0", - "digest": "210889b481b442f6c497a245ebffea24ac3b85bdb0adffb8d061cc7236b6b39e", + "digest": "a843f385794a3bc4fa1d864b221e1bec5482c45180d6b644514d4ccac0cd9851", "service": "accounts", "endpoint": "connect", "method": "/saas.accounts.v1.DatasourceService/PruneSourceOperationReceipts", @@ -28,7 +28,7 @@ { "name": "accounts-connect-reconcile-source", "version": "0.2.0", - "digest": "be1042c57da41fefb9931b166292704af8ad46f0894a57d32f9f46c88d871313", + "digest": "5782459eaa3ad22b429857eea3b0d50805eb73a2cd8a7c364a6c77d82751f13e", "service": "accounts", "endpoint": "connect", "method": "/saas.accounts.v1.DatasourceService/ReconcileSource", diff --git a/module/services/accounts/builder/Dockerfile b/module/services/accounts/builder/Dockerfile index bb9f05e41..b444257de 100644 --- a/module/services/accounts/builder/Dockerfile +++ b/module/services/accounts/builder/Dockerfile @@ -7,7 +7,7 @@ FROM scratch AS gomodproxy # Build stage -FROM golang:1.27.2-alpine3.23@sha256:2ac5c2a64f1f970b5120fe21c6a5e3d9190b196a9ead95797564738ecd07a8a2 AS builder +FROM golang:1.27.3-alpine3.23@sha256:895e5feab11a4b3774d24ee3c9579028456b82ad37f42a9eb6f82adaf776a201 AS builder ARG TARGETARCH diff --git a/module/services/accounts/service.codefly.yaml b/module/services/accounts/service.codefly.yaml index 0a1e6e038..0fc156bd9 100644 --- a/module/services/accounts/service.codefly.yaml +++ b/module/services/accounts/service.codefly.yaml @@ -10,8 +10,8 @@ agent: # codefly whose host support reaches v4 — see scripts/ci/install-codefly.sh. # 0.1.53 additionally carries core v0.15.2 and the fix that lets a scaffolded # service resolve its own address (service-go-grpc#165). - # 0.1.54 builds with Go 1.27.2, including the GO-2026-6613 fix. - version: 0.1.54 + # 0.1.58 builds with Go 1.27.3, including the GO-2026-6809 fix. + version: 0.1.58 publisher: codefly.dev service-dependencies: - name: cache diff --git a/module/services/auth-gateway/builder/Dockerfile b/module/services/auth-gateway/builder/Dockerfile index 7b371c6ab..c61b686b0 100644 --- a/module/services/auth-gateway/builder/Dockerfile +++ b/module/services/auth-gateway/builder/Dockerfile @@ -7,7 +7,7 @@ FROM scratch AS gomodproxy # Build stage -FROM golang:1.27.2-alpine3.23@sha256:2ac5c2a64f1f970b5120fe21c6a5e3d9190b196a9ead95797564738ecd07a8a2 AS builder +FROM golang:1.27.3-alpine3.23@sha256:895e5feab11a4b3774d24ee3c9579028456b82ad37f42a9eb6f82adaf776a201 AS builder ARG TARGETARCH diff --git a/module/services/auth-gateway/service.codefly.yaml b/module/services/auth-gateway/service.codefly.yaml index 128125cec..d4d55ab22 100644 --- a/module/services/auth-gateway/service.codefly.yaml +++ b/module/services/auth-gateway/service.codefly.yaml @@ -12,7 +12,7 @@ description: | agent: kind: codefly:service name: go-grpc - version: 0.1.54 + version: 0.1.58 publisher: codefly.dev service-dependencies: - name: accounts diff --git a/module/services/policy-log/builder/Dockerfile b/module/services/policy-log/builder/Dockerfile index 8b713b77b..8022f031b 100644 --- a/module/services/policy-log/builder/Dockerfile +++ b/module/services/policy-log/builder/Dockerfile @@ -7,7 +7,7 @@ FROM scratch AS gomodproxy # Build stage -FROM golang:1.27.2-alpine3.23@sha256:2ac5c2a64f1f970b5120fe21c6a5e3d9190b196a9ead95797564738ecd07a8a2 AS builder +FROM golang:1.27.3-alpine3.23@sha256:895e5feab11a4b3774d24ee3c9579028456b82ad37f42a9eb6f82adaf776a201 AS builder ARG TARGETARCH diff --git a/module/services/policy-log/service.codefly.yaml b/module/services/policy-log/service.codefly.yaml index b0a8c8ea0..5d9b8763f 100644 --- a/module/services/policy-log/service.codefly.yaml +++ b/module/services/policy-log/service.codefly.yaml @@ -4,7 +4,7 @@ description: Independent signed witness for authority changes agent: kind: codefly:service name: go-grpc - version: 0.1.54 + version: 0.1.58 publisher: codefly.dev workspace-configuration-dependencies: - policy-log diff --git a/scripts/ci/build-images.json b/scripts/ci/build-images.json index 29a599ab5..7ca690f9c 100644 --- a/scripts/ci/build-images.json +++ b/scripts/ci/build-images.json @@ -3,7 +3,7 @@ "source": "https://github.com/codefly-dev/service-go-grpc/blob/main/templates/builder/Dockerfile.tmpl", "images": [ "docker/dockerfile:1@sha256:ecfaec9ed6d810b56388c508f4121597bfbba70d41a6dfeee4d8cad5f295fc32", - "golang:1.27.2-alpine3.23@sha256:2ac5c2a64f1f970b5120fe21c6a5e3d9190b196a9ead95797564738ecd07a8a2", + "golang:1.27.3-alpine3.23@sha256:895e5feab11a4b3774d24ee3c9579028456b82ad37f42a9eb6f82adaf776a201", "alpine:3.23.5@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40" ], "watch": [ From 462a2d8d1417e015146b2e9634f76416922cca2c Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:05:33 +0000 Subject: [PATCH 15/20] refactor(telemetry): reuse SDK tracing and Core declarations --- .../skills/run-the-starter-locally/SKILL.md | 16 +- DOGFOODING.md | 11 +- LOCAL_DOGFOODING.md | 18 +- .../local-dogfood/observability.env.example | 20 -- module/AGENTS.md | 12 +- module/FEATURES.md | 45 ++-- module/configurations/local/observability.env | 22 -- module/module.package.codefly.yaml | 2 +- module/services/accounts/code/go.mod | 3 +- module/services/accounts/code/go.sum | 5 +- .../code/pkg/cataloggen/deployment_model.go | 36 +-- .../pkg/cataloggen/deployment_topology.go | 8 +- .../cataloggen/deployment_topology_test.go | 2 +- .../cataloggen/endpoint_declarations_test.go | 27 +-- module/services/accounts/code/telemetry.go | 38 +--- .../services/accounts/code/telemetry_test.go | 209 ++++-------------- module/services/accounts/code/work.go | 51 ++--- module/services/accounts/service.codefly.yaml | 1 - module/services/auth-gateway/code/go.mod | 3 +- module/services/auth-gateway/code/go.sum | 5 +- module/services/auth-gateway/code/main.go | 32 +-- .../services/auth-gateway/code/telemetry.go | 38 +--- .../auth-gateway/code/telemetry_test.go | 209 ++++-------------- .../auth-gateway/service.codefly.yaml | 1 - topology_declarations_test.go | 4 +- topology_manifests.go | 43 +--- 26 files changed, 207 insertions(+), 654 deletions(-) delete mode 100644 configurations/local-dogfood/observability.env.example delete mode 100644 module/configurations/local/observability.env diff --git a/.claude/skills/run-the-starter-locally/SKILL.md b/.claude/skills/run-the-starter-locally/SKILL.md index bbf70287c..099fc27e9 100644 --- a/.claude/skills/run-the-starter-locally/SKILL.md +++ b/.claude/skills/run-the-starter-locally/SKILL.md @@ -23,14 +23,14 @@ codefly run service --fixture dev-admin - No TTY (CI, pipes, MCP) auto-enables `--headless`. - Docker must be running. `codefly doctor` checks prerequisites; `codefly clear` reaps stray processes and containers between runs. -- A local run has no cell collector, and the module ships none. Traces and - metrics follow one standard variable, `OTEL_EXPORTER_OTLP_ENDPOINT`, in the - process environment: set it and the services export over OTLP, leave it unset - and they export nothing over OTLP. Only the runtime the SDK identifies as - `local` sends traces to wool's stdout tracer in that case; the `local-dogfood` - profile is not that runtime, so with no endpoint it exports no traces or - metrics at all. The module's `configurations/local/observability.env` names no - collector and nothing reads it. +- The module ships no collector. Standard OpenTelemetry process variables + select export: `OTEL_EXPORTER_OTLP_ENDPOINT` names the collector, with + `OTEL_SERVICE_NAME` and `OTEL_RESOURCE_ATTRIBUTES` supplying identity. + CLIs implementing [CLI #1097](https://github.com/codefly-dev/cli/pull/1097) + also deliver these for every service through an environment's `telemetry:` + block; scoped platform `service-config` delivery needs no such block. + Without an endpoint, SDK Init enables no tracing, including for `local`. + No module configuration group is required. For a real external identity provider and the production-grade provider stack, use the `local-dogfood` environment and its configuration groups: diff --git a/DOGFOODING.md b/DOGFOODING.md index ad02ddae0..31690ee8a 100644 --- a/DOGFOODING.md +++ b/DOGFOODING.md @@ -214,12 +214,17 @@ Admin Portal integration; `IDENTITY_CLIENT_ID` and Configure the remaining adapters as Codefly configuration groups, then start the same Codefly-managed graph. For each of `email` (Resend), -`product-analytics` (PostHog), `error-tracking` (Sentry), `observability` and +`product-analytics` (PostHog), `error-tracking` (Sentry) and `abuse-protection` (Turnstile), copy `configurations/local-dogfood/.env.example` and `.secret.env.example` to their Git-ignored names and fill them in ([LOCAL_DOGFOODING.md](./LOCAL_DOGFOODING.md#configure-providers-through-codefly)). -Keep `observability.env.example` as it is for a `local-dogfood` run with no collector: with `OTEL_EXPORTER_OTLP_ENDPOINT` unset in the process environment nothing is exported over OTLP, and `local-dogfood` is not the plain `local` runtime, whose traces go to wool's stdout tracer, so a run with no collector exports no traces or metrics. +Operational telemetry has no configuration group. Supply the standard +`OTEL_EXPORTER_OTLP_ENDPOINT` process variable through scoped platform +`service-config` delivery. CLIs implementing +[CLI #1097](https://github.com/codefly-dev/cli/pull/1097) also support the +environment's optional `telemetry:` block. +With no endpoint, no traces or metrics are exported, including native local runs. Turnstile's published test keys drive the three abuse-protection cases: | Case | `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | `TURNSTILE_SECRET_KEY` | @@ -239,7 +244,7 @@ codefly run service --env local-dogfood - [ ] Grant analytics consent, navigate through onboarding, and verify bounded browser plus durable backend events in PostHog. - [ ] Withdraw analytics consent or log out. Browser capture stops immediately and identity resets. - [ ] Trigger controlled browser and backend errors. Sentry correlates release and environment without creating performance transactions. -- [ ] With `OTEL_EXPORTER_OTLP_ENDPOINT` unset, check that accounts and auth-gateway log once at startup that OTLP export is disabled, then exercise login/onboarding; no traces or metrics are exported in `local-dogfood` (wool's stdout tracer is for the plain `local` runtime), so observe the logs on stdout. To see traces, set `OTEL_EXPORTER_OTLP_ENDPOINT` to a collector this machine can reach, for example `http://localhost:4317`, and confirm the startup line says export is enabled. Set it to a URL with no port and confirm the service refuses to start, naming the rule. +- [ ] With `OTEL_EXPORTER_OTLP_ENDPOINT` unset, exercise login/onboarding and observe stdout logs. With a reachable collector configured, confirm trace and metric delivery. An invalid exporter setting must be logged without preventing the service from starting. - [ ] Switch Turnstile to the **fail** keys. Registration and waitlist submission fail without database writes. - [ ] Switch Turnstile to the **replay** keys. The first deterministic verification follows Cloudflare's fixture behavior; replay rejection leaves state unchanged. diff --git a/LOCAL_DOGFOODING.md b/LOCAL_DOGFOODING.md index 2e0c9ea01..5cbffb424 100644 --- a/LOCAL_DOGFOODING.md +++ b/LOCAL_DOGFOODING.md @@ -49,21 +49,17 @@ plugin, which validates the account, observes the webhook and projects the | `email` | Resend | | `product-analytics` | PostHog | | `error-tracking` | Sentry | -| `observability` | none: the collector is the standard `OTEL_EXPORTER_OTLP_ENDPOINT` variable, below | | `abuse-protection` | Cloudflare Turnstile | Validate the result with `codefly doctor workspace --env local-dogfood`. -Traces and metrics follow one standard variable, `OTEL_EXPORTER_OTLP_ENDPOINT`, -in the environment the services run with: set it and `accounts` and `auth-gateway` -export over OTLP/gRPC, leave it unset and they export nothing over OTLP. That is -the default here, so `observability` needs no setup (logs still go to stdout): -`local-dogfood` is not the `local` runtime, and wool's stdout tracer belongs to -that runtime alone. To export to a collector this machine can reach, set the -variable to its address, for example `http://localhost:4317`. A value that names -no port, or carries credentials, stops the service at startup with the reason. -The group's own file, `observability.env`, exists only so the group does; nothing -reads the key in it. +Operational telemetry uses standard process variables rather than a configuration +group. With no `OTEL_EXPORTER_OTLP_ENDPOINT`, the SDK exports no traces in any +environment, and the services build no metrics exporter. To export, deliver the +endpoint as scoped platform `service-config`. CLIs implementing +[CLI #1097](https://github.com/codefly-dev/cli/pull/1097) also support the +environment's optional `telemetry:` block, which applies to every service. SDK exporter failures are +logged without stopping the service; logs always go to stdout. `email` is required because this profile is not the `local` environment, and accounts refuses to start there without an explicit `EMAIL_PROVIDER`: `resend` diff --git a/configurations/local-dogfood/observability.env.example b/configurations/local-dogfood/observability.env.example deleted file mode 100644 index 4bc1ecebd..000000000 --- a/configurations/local-dogfood/observability.env.example +++ /dev/null @@ -1,20 +0,0 @@ -# Copy to observability.env. This module owns no collector: the cell's collector is -# where traces and metrics go, and the platform delivers its address as the standard -# OTEL_EXPORTER_OTLP_ENDPOINT variable in the process environment, or delivers nothing. -# -# A dogfood run on a laptop is not a cell, so by default it has none, and then -# neither traces nor metrics are exported: this profile is not the plain `local` -# runtime, and wool's stdout tracer belongs to that runtime alone. Logs still go to -# stdout. The empty assignment below is that default, and is here only so the group -# exists; nothing reads it, because a group value arrives as -# CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__OTEL_EXPORTER_OTLP_ENDPOINT. -# -# To export to a collector this machine can reach (for example a cell's, through a -# port-forward), set the standard variable in the environment the services run with: -# -# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 -# -# The endpoint is the collector's OTLP/gRPC address. http:// is plaintext, which -# only belongs on loopback or inside the mesh; https:// is TLS. A value that names no -# port, or carries credentials, stops the service at startup with the reason. -OTEL_EXPORTER_OTLP_ENDPOINT= diff --git a/module/AGENTS.md b/module/AGENTS.md index a841e9f67..afdd6fe4c 100644 --- a/module/AGENTS.md +++ b/module/AGENTS.md @@ -85,14 +85,15 @@ which is only for older CLIs and reuses fixed local database directories across otherwise independent runs and migration revisions. Core is pinned per Go module. Accounts and auth-gateway currently select -candidate presence/delivery revisions for the lifecycle PR; the gateway also -selects the SDK candidate providing `ResolveDeliveredNetworkInstance`. +candidate presence/delivery revisions for the lifecycle PR. Released SDK +v0.4.1 includes the lifecycle APIs and owns tracing startup and shutdown. Their `go.mod` files are the exact source pins. The other services and tools retain their own released pins. These are development candidates, not release qualification: never ship a host release on an untagged pseudo-version. Run qualification with `GOWORK=off` so a workspace cannot mask the pins. -Accounts requires Go 1.27.2. All go-grpc services pin released agent 0.1.54, -whose builder uses Go 1.27.2 under `GOTOOLCHAIN=local`; CI uses the same patch. +Accounts requires Go 1.27.2. All go-grpc services pin released agent 0.1.58, +whose builder uses Go 1.27.3 under `GOTOOLCHAIN=local`; CI uses the same patch. +Go 1.27.3 also fixes GO-2026-6809. Go 1.27.0 is affected by [GO-2026-6613](https://pkg.go.dev/vuln/GO-2026-6613). A source audit under a newer compiler does not qualify the compiler used in the image: move the owning agent and image contract together, regenerate the recipe, @@ -112,7 +113,8 @@ release for development qualification. The installer refuses release tags while the source candidate is selected. Restore a qualified released pin before publishing the host. -Outside the gateway's delivery candidate, services use released SDK v0.4.0. +Accounts and auth-gateway use released SDK v0.4.1; other services retain their +own released pins. Their listeners resolve allocated endpoints through `ResolveNetworkInstance`, which selects the delivered or local endpoint from the runtime environment; the removed `WithDefaultNetwork` switch diff --git a/module/FEATURES.md b/module/FEATURES.md index f1d88e6a8..2ca7e7c22 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -756,27 +756,30 @@ Environment variables consumed by the api: | `TURNSTILE_ALLOWED_HOSTNAMES` | Exact accepted Turnstile response hostnames | | `CODEFLY__FIXTURE` | Loads fixture YAML (e.g. `dev-admin`); FE login picker too | -When `OTEL_EXPORTER_OTLP_ENDPOINT` is set, the accounts service and the -auth-gateway export traces and unsampled request and Go runtime metrics over -OTLP/gRPC to the collector it names. The auth-gateway covers both its HTTP gateway -and gRPC ext_authz authorization service. That variable is the whole contract: -core's `wool/otel` reads it, tells a URL from a `host:port`, takes the transport -from the scheme, and refuses a URL with no port or with credentials, and the -metrics exporter reads it from the same environment. When it is unset the services -export nothing over OTLP and start normally: no state, reason or protocol is -delivered or read, and a process that is given no collector does not fail to -start. A local runtime, meaning the Codefly environment named `local` and no -other, then uses wool's stdout tracer for traces; `local-dogfood` is not that -runtime, so it exports no traces either. The endpoint decides, not where the -process runs: a collector delivered to a pod in an environment named `local` is -exported to. A value the metrics exporter cannot read (anything but an `http://` -or `https://` URL, which would otherwise make it dial `localhost:4317` in -silence) stops the service at startup. -Metrics leave by OTLP push alone — decided 2026-10-06, because the cell's -collector is the record for traces and metrics take the same path — so neither -service serves a scrape endpoint. `service.name` comes from `OTEL_SERVICE_NAME` or -`OTEL_RESOURCE_ATTRIBUTES` when the platform sets it, else the service's own -Codefly identity (`module/service`, the one wool puts on its log lines). +The SDK's `Init` enables tracing when `OTEL_EXPORTER_OTLP_ENDPOINT` is +present, and `ShutdownTelemetry` flushes traces on service shutdown. Services +with no endpoint export nothing, including native `local` runs. Invalid exporter +configuration is logged without stopping the service. Accounts and auth-gateway +also install an OTLP metrics provider for unsampled request and Go runtime +metrics; no scrape endpoint is served. + +Delivery uses OpenTelemetry process variables, not a module configuration group. +The platform can deliver scoped `service-config` values to admitted workloads; +CLIs implementing [CLI #1097](https://github.com/codefly-dev/cli/pull/1097) +can instead supply the standard variables to every service through the +environment's optional `telemetry:` block. The current lifecycle CLI pin does +not need that block for scoped `service-config` delivery. `OTEL_SERVICE_NAME` +selects trace identity, falling back to the SDK's `module/service` identity. +Metrics also consume `OTEL_RESOURCE_ATTRIBUTES`. Core currently forwards only +`service.name` and `library.name` to traces, so arbitrary resource attributes +are not yet attached to trace exports. + +| Build or reuse | Verified implementation | Decision | +| --- | --- | --- | +| Tracing lifecycle and identity | Released SDK v0.4.1 [Init / ShutdownTelemetry](https://github.com/codefly-dev/sdk-go/blob/v0.4.1/codefly.go#L66), using Core wool/otel | Reuse SDK lifecycle; remove per-service tracing bootstrap and its duplicate tests. | +| Endpoint declaration validation | Core [Endpoint](https://github.com/codefly-dev/core/blob/v0.18.0/resources/endpoint.go#L134) / [InterfaceEndpoint](https://github.com/codefly-dev/core/blob/v0.18.0/resources/module.go#L69) YAML decoders | Reuse the Core decoders before projecting deployment fields. | +| Metrics export and runtime instruments | OpenTelemetry OTLP metrics exporter and runtime instrumentation v0.71.0; Core/SDK provide no metrics bootstrap | Keep service wiring with the standard exporter. The services remain separate Go modules; no additional shared framework is introduced. | +| GC activity instruments | Runtime v0.71.0 default instruments omit cycle count and cumulative GC pause CPU time; deprecated pause instruments measure wall time instead | Retain existing `go.gc.cycle.count` and `go.gc.pause.cpu_time` callbacks and their export assertions. | Frontend browser configuration. The server reads each value from its Codefly group per request (`identity`, `legal`, `product-features`, `product-analytics`, `error-tracking`, diff --git a/module/configurations/local/observability.env b/module/configurations/local/observability.env deleted file mode 100644 index 857c7bd7d..000000000 --- a/module/configurations/local/observability.env +++ /dev/null @@ -1,22 +0,0 @@ -# Where accounts and auth-gateway send traces and metrics: one standard variable, -# OTEL_EXPORTER_OTLP_ENDPOINT, the collector's OTLP/gRPC address, e.g. -# http://otel-collector.otel-collector.svc.cluster.local:4317 (http:// is plaintext -# because the mesh supplies mTLS; https:// is TLS). The platform delivers it, in the -# process environment, when the cell has a collector the workload may reach, and -# delivers nothing when it does not. Deliver it and the process exports over OTLP; -# deliver none and it exports nothing. Core's wool/otel and the OpenTelemetry metrics -# exporter read the variable from the process environment themselves, so there is no -# switch, no state and no reason to set. -# -# This profile names no collector, on purpose: module defaults also reach deployed -# cells, and a collector written here would be inherited by a cell that never set it. -# The one assignment below is empty, which is no endpoint, and is here only so the -# group exists: a composing workspace inherits a group from the key it assigns, and a -# group with no key is met at runtime-init as "no configuration found for -# observability". Nothing reads this key: a group value arrives as -# CODEFLY__WORKSPACE_CONFIGURATION__OBSERVABILITY__OTEL_EXPORTER_OTLP_ENDPOINT, and -# the standard variable is what points a run at a collector. -# -# A local run with no endpoint exports nothing over OTLP: its traces go to wool's -# stdout tracer, and logs always go to stdout. -OTEL_EXPORTER_OTLP_ENDPOINT= diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index f0d13962f..a8b4ba95a 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -100,7 +100,7 @@ migrations: breaking-changes: - Consumers resolve the immutable package into a disposable projection instead of editing Starter-owned files. - "Audit event types are namespaced under `saas.*` (`auth.login` -> `saas.auth.login`). The wire value of `audit_events.event_type`, the `X-Webhook-Event` header, and webhook routing keys all change; migration 116 rewrites stored history and existing subscriptions in place, so a live subscriber keeps firing, but a consumer that re-creates a subscription from a hardcoded pre-namespace name, or emits one through `ModuleEmitAuditEvent`, is now rejected. No alias layer." - - "The `telemetry` service is removed: the module no longer ships an OTLP gateway, and the cell's collector takes its place. `accounts` and `auth-gateway` export traces and metrics over OTLP/gRPC when the standard `OTEL_EXPORTER_OTLP_ENDPOINT` variable is set in their process environment (an `http://` endpoint is plaintext on the wire because the mesh supplies mTLS; `https://` is TLS) and export nothing over OTLP when it is not; that one variable is the whole contract, so no state, reason or protocol key is delivered or read and a process given no collector starts normally. Core's `wool/otel` reads and validates the variable, and the services stop at startup on a value it refuses or that the metrics exporter cannot read. `OBSERVABILITY_EXPORTER` and `OTEL_EXPORTER_OTLP_HEADERS` no longer mean anything, the `/metrics` scrape endpoint of both services is gone (metrics leave by OTLP push only), and `service.name` is no longer fixed in code: `OTEL_SERVICE_NAME` or `OTEL_RESOURCE_ATTRIBUTES` sets it, else the service's own Codefly identity. A consumer that depended on the `telemetry` service, or that sets the collector through the `observability` configuration group, must set `OTEL_EXPORTER_OTLP_ENDPOINT` in the services' environment instead; the group's own key is no longer read." + - "The telemetry service and its dependency contract are removed. SDK Init exports traces when OTEL_EXPORTER_OTLP_ENDPOINT is delivered; no endpoint means no tracing, including native local runs. Exporter initialization failures are logged without stopping the service. Accounts and auth-gateway push metrics to the same endpoint and no longer serve /metrics. Telemetry uses standard process variables, not the removed observability configuration group. Deliver OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_SERVICE_NAME through scoped service-config or a compatible CLI environment telemetry declaration. Existing consumers of the telemetry service must migrate to the platform collector." reserved-namespaces: - "@codefly/saas-" - "@codefly-dev/saas-" diff --git a/module/services/accounts/code/go.mod b/module/services/accounts/code/go.mod index 6ccd7e1e7..8166c024d 100644 --- a/module/services/accounts/code/go.mod +++ b/module/services/accounts/code/go.mod @@ -11,7 +11,7 @@ require ( github.com/codefly-dev/core v0.19.6-0.20261018171306-0a8d16c5e32d github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17 - github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 + github.com/codefly-dev/sdk-go v0.4.1 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 github.com/codefly-dev/service-postgres/libs/go v0.0.0-20260929200903-bae6c31c7211 github.com/codefly-dev/service-redis/cache v0.2.0 @@ -90,7 +90,6 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/kr/text v0.2.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect github.com/mattn/go-colorable v0.1.15 // indirect diff --git a/module/services/accounts/code/go.sum b/module/services/accounts/code/go.sum index 0084cff1b..506f712bd 100644 --- a/module/services/accounts/code/go.sum +++ b/module/services/accounts/code/go.sum @@ -66,8 +66,8 @@ github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba/go.mod h1:OQDNwz4rpEGfq7VC/FjvmQGlo9H8gH8fIDcZhKIe8iQ= github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17 h1:ppOzy5ops7mEO9PApDfIcAiUBNy4z1MHOf3UJ8xXNNw= github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17/go.mod h1:OxvwxMdo3HD190xFiMOPHtUsBC7/7Jt1evsEts/2HRs= -github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 h1:hi+KhotTLMOJ/PUSzVO/Axfho7yZ2UOjrCFfrLI1EKU= -github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458/go.mod h1:Qg8SMzZxKSllwvZwShMrWgSetBVMjxP0s17mwWHWjk0= +github.com/codefly-dev/sdk-go v0.4.1 h1:WUKYWxXfojTr88SkLtq0T8aqpV0o5pR/muUUcsUUlzk= +github.com/codefly-dev/sdk-go v0.4.1/go.mod h1:zr9ts3kkWNv2XEFUXZB+ydfW6x5b6/NbUvBm4KVYNqs= github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 h1:wBOL7QsuoC0HsS43gsH2+tpsipk4os077C2VUDkMUHg= github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458/go.mod h1:hwtrdGtgh8i2xzjgihyd4wY2u+UK7nHQz30OGKL0n+M= github.com/codefly-dev/service-postgres/libs/go v0.0.0-20260929200903-bae6c31c7211 h1:4KAPx8haCA/h4Kx5mB7D0rMriwLLKiOW9G9mMWuiQDw= @@ -78,7 +78,6 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/module/services/accounts/code/pkg/cataloggen/deployment_model.go b/module/services/accounts/code/pkg/cataloggen/deployment_model.go index 14677bd34..23853385d 100644 --- a/module/services/accounts/code/pkg/cataloggen/deployment_model.go +++ b/module/services/accounts/code/pkg/cataloggen/deployment_model.go @@ -8,8 +8,8 @@ import ( "os" "path/filepath" "sort" - "strings" + "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/standards" "gopkg.in/yaml.v3" ) @@ -351,39 +351,11 @@ func standardAllocation(name, api string, endpoints []manifestEndpoint) (uint32, return port, true } -func (endpoint *manifestEndpoint) UnmarshalYAML(node *yaml.Node) error { - if err := refuseAuthoredConsumers(node); err != nil { - return err - } - type plain manifestEndpoint - return node.Decode((*plain)(endpoint)) -} - func (endpoint *deploymentInterfaceBinding) UnmarshalYAML(node *yaml.Node) error { - if err := refuseAuthoredConsumers(node); err != nil { + var declaration resources.InterfaceEndpoint + if err := node.Decode(&declaration); err != nil { return err } - type plain deploymentInterfaceBinding - return node.Decode((*plain)(endpoint)) -} - -// Judge key presence before decoding: null and alternate spellings cannot -// silently erase a forbidden grant. This matches Core's declaration boundary. -func refuseAuthoredConsumers(node *yaml.Node) error { - if node.Kind != yaml.MappingNode { - return nil - } - for i := 0; i+1 < len(node.Content); i += 2 { - key := node.Content[i].Value - var canonical strings.Builder - for _, r := range strings.ToLower(key) { - if r >= 'a' && r <= 'z' { - canonical.WriteRune(r) - } - } - if canonical.String() == "allowmodules" { - return fmt.Errorf("authors %q: endpoint access is derived from consumers' declared dependencies", key) - } - } + endpoint.Service, endpoint.Endpoint, endpoint.Visibility = declaration.Service, declaration.Endpoint, declaration.Visibility return nil } diff --git a/module/services/accounts/code/pkg/cataloggen/deployment_topology.go b/module/services/accounts/code/pkg/cataloggen/deployment_topology.go index 0595e5e47..83585aae4 100644 --- a/module/services/accounts/code/pkg/cataloggen/deployment_topology.go +++ b/module/services/accounts/code/pkg/cataloggen/deployment_topology.go @@ -8,6 +8,7 @@ import ( "sort" "strings" + "github.com/codefly-dev/core/resources" "google.golang.org/protobuf/encoding/protojson" "gopkg.in/yaml.v3" @@ -731,12 +732,7 @@ type manifestEndpointReference struct { Name string `yaml:"name"` } -type manifestEndpoint struct { - Name string `yaml:"name"` - Visibility string `yaml:"visibility,omitempty"` - API string `yaml:"api,omitempty"` - Location string `yaml:"location,omitempty"` -} +type manifestEndpoint = resources.Endpoint func marshalGeneratedYAML(source string, value any) ([]byte, error) { document, err := yaml.Marshal(value) diff --git a/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go b/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go index 577dab153..68daf09fc 100644 --- a/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go +++ b/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go @@ -185,7 +185,7 @@ func TestDeploymentTopologyRefusesUnrepresentableEndpointPolicies(t *testing.T) " - name: connect\n visibility: internal\n", " - name: connect\n visibility: internal\n allow-modules: [\"*\"]\n") _, err = cataloggen.BuildDeploymentArtifacts(catalog, legacy) - require.ErrorContains(t, err, "derived from consumers", "legacy wildcard grants must refuse") + require.ErrorIs(t, err, resources.ErrInvalidEndpointDeclaration, "legacy wildcard grants must refuse") } // The manifests are the model: a deployment fact lives in the service manifest diff --git a/module/services/accounts/code/pkg/cataloggen/endpoint_declarations_test.go b/module/services/accounts/code/pkg/cataloggen/endpoint_declarations_test.go index 46eb3ebb2..62d923939 100644 --- a/module/services/accounts/code/pkg/cataloggen/endpoint_declarations_test.go +++ b/module/services/accounts/code/pkg/cataloggen/endpoint_declarations_test.go @@ -1,33 +1,16 @@ package cataloggen_test import ( - "strings" "testing" "accounts/pkg/cataloggen" + "github.com/codefly-dev/core/resources" "github.com/stretchr/testify/require" ) -func withoutAuthoredAllowModules(documents cataloggen.DeploymentDocuments) cataloggen.DeploymentDocuments { - strip := func(document []byte) []byte { - var lines []string - for _, line := range strings.Split(string(document), "\n") { - if !strings.HasPrefix(strings.TrimSpace(line), "allow-modules:") { - lines = append(lines, line) - } - } - return []byte(strings.Join(lines, "\n")) - } - result := cataloggen.DeploymentDocuments{Module: strip(documents.Module), Services: map[string][]byte{}, Jobs: documents.Jobs} - for name, document := range documents.Services { - result.Services[name] = strip(document) - } - return result -} - func TestDeploymentTopologyAcceptsConsumerDerivedInternalReach(t *testing.T) { - documents := withoutAuthoredAllowModules(readDeploymentDocuments(t)) + documents := readDeploymentDocuments(t) artifacts, err := cataloggen.BuildDeploymentArtifacts(readFixture(t, "../../../generated/service-catalog.json"), documents) require.NoError(t, err) // The reach category does not grant callers. The policies are still the @@ -43,17 +26,17 @@ func TestDeploymentTopologyAcceptsConsumerDerivedInternalReach(t *testing.T) { } func TestDeploymentTopologyRefusesAuthoredConsumersByKeyPresence(t *testing.T) { - documents := withoutAuthoredAllowModules(readDeploymentDocuments(t)) + documents := readDeploymentDocuments(t) catalog := readFixture(t, "../../../generated/service-catalog.json") for _, key := range []string{"allow-modules", "allow_modules", "allowModules", "Allow Modules"} { for _, value := range []string{"[\"*\"]", "[example]", "[]", "null"} { t.Run(key+"/"+value, func(t *testing.T) { _, err := cataloggen.BuildDeploymentArtifacts(catalog, withModule(t, documents, " visibility: internal\n", " visibility: internal\n "+key+": "+value+"\n")) - require.ErrorContains(t, err, "derived from consumers") + require.ErrorIs(t, err, resources.ErrInvalidEndpointDeclaration) _, err = cataloggen.BuildDeploymentArtifacts(catalog, withService(t, documents, "accounts", " visibility: internal\n", " visibility: internal\n "+key+": "+value+"\n")) - require.ErrorContains(t, err, "derived from consumers") + require.ErrorIs(t, err, resources.ErrInvalidEndpointDeclaration) }) } } diff --git a/module/services/accounts/code/telemetry.go b/module/services/accounts/code/telemetry.go index f25d0d03e..74ba1ed51 100644 --- a/module/services/accounts/code/telemetry.go +++ b/module/services/accounts/code/telemetry.go @@ -3,13 +3,11 @@ package main import ( "context" "errors" - "fmt" "net/url" "os" "strings" "github.com/codefly-dev/core/wool" - wooltel "github.com/codefly-dev/core/wool/otel" codefly "github.com/codefly-dev/sdk-go" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/sdk/resource" @@ -34,34 +32,10 @@ func otlpEndpointConfigured() bool { return strings.TrimSpace(os.Getenv(otlpEndpointVariable)) != "" } -// enableTracing registers wool's tracer: OTLP/gRPC to the collector when the -// environment names one, with wool's own stdout tracer in its place on a local -// run that names none, and no tracer at all otherwise. A nil provider means -// nothing was registered. -// -// wool/otel owns the endpoint: it reads the variable, so a value it refuses (no -// scheme, no port, credentials in the URL) is returned as its error and nothing is -// registered. -func enableTracing(ctx context.Context, local bool) (*wooltel.Provider, error) { - configured := otlpEndpointConfigured() - if !configured && !local { - return nil, nil - } - res, err := currentTelemetryResource(ctx) - if err != nil { - return nil, fmt.Errorf("resolve telemetry resource: %w", err) - } - options := []wooltel.Option{wooltel.WithServiceName(telemetryServiceName(res))} - if !configured { - options = append(options, wooltel.WithStdout()) - } - return wooltel.Enable(options...) -} - // requireMetricsEndpoint refuses what the metrics exporter cannot read. It reads // the variable only as an http:// or https:// URL, and on anything else keeps its // default and dials localhost:4317 without saying so, so a value of another shape -// (wool still accepts a bare host:port for traces) stops the process here. +// is rejected here instead of creating a metrics exporter at a default address. func requireMetricsEndpoint() error { if !otlpEndpointConfigured() { return errors.New("no " + otlpEndpointVariable + " to export metrics to") @@ -73,7 +47,7 @@ func requireMetricsEndpoint() error { return nil } -// telemetryResource is the one resource both signals carry. service.name is +// telemetryResource builds the metrics resource. service.name is // whatever the environment says — OTEL_SERVICE_NAME, or service.name in // OTEL_RESOURCE_ATTRIBUTES — and otherwise the service identity the Codefly SDK // holds. No name is typed here: a literal would override the environment, so the @@ -111,11 +85,3 @@ func codeflyServiceName(ctx context.Context) string { // An identity with no module reads `/service`; with neither, `/`. return strings.Trim(strings.TrimSpace(source.Unique), "/") } - -// telemetryServiceName is the service.name the resource resolved to. -func telemetryServiceName(res *resource.Resource) string { - if value, ok := res.Set().Value(attribute.Key("service.name")); ok { - return value.AsString() - } - return "" -} diff --git a/module/services/accounts/code/telemetry_test.go b/module/services/accounts/code/telemetry_test.go index bca3591df..b0611abc8 100644 --- a/module/services/accounts/code/telemetry_test.go +++ b/module/services/accounts/code/telemetry_test.go @@ -3,16 +3,18 @@ package main import ( "context" "net" - "os" "strings" "testing" "time" - "github.com/codefly-dev/core/wool" - "github.com/stretchr/testify/require" + "github.com/codefly-dev/core/resources" + codefly "github.com/codefly-dev/sdk-go" "go.opentelemetry.io/otel" collectortracev1 "go.opentelemetry.io/proto/otlp/collector/trace/v1" "google.golang.org/grpc" + + "github.com/codefly-dev/core/wool" + "github.com/stretchr/testify/require" ) // isolateOTLPEnvironment clears every OTLP variable the exporters read, so what a @@ -44,32 +46,6 @@ func collectorAt(t *testing.T, scheme, hostPort string) { t.Setenv(otlpEndpointVariable, scheme+"://"+hostPort) } -// shippedObservabilityProfile is what the module's own `observability` profile -// contributes to the group, read from the file it ships. -func shippedObservabilityProfile(t *testing.T) map[string]string { - t.Helper() - contents, err := os.ReadFile("../../../configurations/local/observability.env") - require.NoError(t, err) - values := map[string]string{} - for _, line := range strings.Split(string(contents), "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - key, value, ok := strings.Cut(line, "=") - require.True(t, ok) - values[key] = value - } - return values -} - -// Module defaults also reach deployed cells, so the profile names no collector: -// the group exists (a composing workspace inherits a group from the key it -// assigns), and the one variable it assigns is empty, which is no endpoint. -func TestShippedObservabilityProfileNamesNoCollector(t *testing.T) { - require.Equal(t, map[string]string{otlpEndpointVariable: ""}, shippedObservabilityProfile(t)) -} - func TestOTLPEndpointConfigured(t *testing.T) { isolateOTLPEnvironment(t) require.False(t, otlpEndpointConfigured(), "unset") @@ -86,7 +62,8 @@ func TestTelemetryResourceServiceName(t *testing.T) { t.Helper() res, err := telemetryResource(t.Context(), identity) require.NoError(t, err) - return telemetryServiceName(res) + value, _ := res.Set().Value("service.name") + return value.AsString() } t.Run("falls back to the Codefly service identity", func(t *testing.T) { @@ -124,150 +101,56 @@ func TestCodeflyServiceNameIsTheSDKProviderIdentity(t *testing.T) { require.Empty(t, codeflyServiceName(t.Context()), "no provider: the SDK was not initialised") } -func restoreTracing(t *testing.T) { - t.Helper() - previous := otel.GetTracerProvider() +// SDK Init must deliver a real span with the supplied service identity; its +// public shutdown flushes the batch before the process exits. +func TestSDKExportsTracesAndFlushesOnShutdown(t *testing.T) { + isolateOTLPEnvironment(t) + t.Setenv(resources.ModulePrefix, "saas-starter") + t.Setenv(resources.ServicePrefix, "accounts") + t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") + previousTracer := otel.GetTracerProvider() + previousTelemetry := wool.GetTelemetry() t.Cleanup(func() { - wool.RegisterTelemetry(nil) - otel.SetTracerProvider(previous) + _ = codefly.ShutdownTelemetry(context.Background()) + otel.SetTracerProvider(previousTracer) + wool.RegisterTelemetry(previousTelemetry) }) -} - -// With no endpoint delivered the process registers no OTLP exporter: nothing -// is exported, and nothing dials a default address. -func TestEnableTracingWithoutAnEndpointRegistersNothingOutsideALocalRun(t *testing.T) { - restoreTracing(t) - isolateOTLPEnvironment(t) - provider, err := enableTracing(t.Context(), false) + listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) - require.Nil(t, provider) - require.False(t, wool.TelemetryEnabled()) -} - -// A local run with no endpoint keeps wool's own stdout tracer, which exports -// nothing over OTLP. -func TestEnableTracingWithoutAnEndpointUsesWoolsStdoutTracerInALocalRun(t *testing.T) { - restoreTracing(t) - isolateOTLPEnvironment(t) - provider, err := enableTracing(t.Context(), true) + capture := &sdkTraceCapture{requests: make(chan *collectortracev1.ExportTraceServiceRequest, 1)} + server := grpc.NewServer() + collectortracev1.RegisterTraceServiceServer(server, capture) + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { server.Stop(); _ = listener.Close() }) + t.Setenv(otlpEndpointVariable, "http://"+listener.Addr().String()) + _, err = codefly.Init(t.Context()) require.NoError(t, err) - require.NotNil(t, provider) - require.True(t, wool.TelemetryEnabled()) - shutdown(t, provider) -} - -// The endpoint decides, not where the process runs: a pod deployed into an -// environment named `local` that is handed a collector exports to it, rather -// than falling back to the stdout tracer. -func TestEnableTracingExportsToTheDeliveredEndpointWhetherOrNotTheRunIsLocal(t *testing.T) { - for name, local := range map[string]bool{"deployed": false, "local environment": true} { - t.Run(name, func(t *testing.T) { - restoreTracing(t) - t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") - t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") - endpoint, capture := startTraceCapture(t) - collectorAt(t, "http", endpoint) - provider, err := enableTracing(t.Context(), local) - require.NoError(t, err) - require.NotNil(t, provider) - require.True(t, wool.TelemetryEnabled()) - - _, span := otel.Tracer("test").Start(t.Context(), "operation") - span.End() - shutdown(t, provider) // flushes the batch - - select { - case request := <-capture.requests: - var name string - for _, resourceSpans := range request.GetResourceSpans() { - for _, attribute := range resourceSpans.GetResource().GetAttributes() { - if attribute.GetKey() == "service.name" { - name = attribute.GetValue().GetStringValue() - } - } - } - require.Equal(t, "named-by-the-platform", name) - case <-time.After(5 * time.Second): - t.Fatal("timed out waiting for OTLP spans") + _, span := otel.Tracer("test").Start(t.Context(), "operation") + span.End() + shutdownCtx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + require.NoError(t, codefly.ShutdownTelemetry(shutdownCtx)) + select { + case request := <-capture.requests: + require.Len(t, request.ResourceSpans, 1) + for _, attribute := range request.ResourceSpans[0].Resource.Attributes { + if attribute.Key == "service.name" { + require.Equal(t, "named-by-the-platform", attribute.Value.GetStringValue()) + return } - }) - } -} - -// core's wool/otel owns what an endpoint may be, and a value it refuses stops -// the process with its reason and registers nothing — never a tracer pointed at a -// default address. -func TestEnableTracingReturnsWhatWoolRefusesAndRegistersNothing(t *testing.T) { - for name, value := range map[string]string{ - "no port": "http://otel-collector.otel-collector.svc.cluster.local", - "userinfo": "http://user:secret-pass@collector.example:4317", - } { - t.Run(name, func(t *testing.T) { - restoreTracing(t) - isolateOTLPEnvironment(t) - t.Setenv(otlpEndpointVariable, value) - provider, err := enableTracing(t.Context(), false) - require.Error(t, err) - require.NotContains(t, err.Error(), "secret-pass", "the refusal never echoes the URL") - require.Nil(t, provider) - require.False(t, wool.TelemetryEnabled()) - }) + } + t.Fatal("exported trace has no service.name") + case <-time.After(5 * time.Second): + t.Fatal("SDK shutdown did not deliver the span") } } -// wool takes a value that is not an http:// or https:// URL for a bare -// host:port, the shape it accepted before it read URLs, and dials it in -// plaintext. The metrics exporter reads only a URL and, given anything else, -// keeps its default, localhost:4317. So the tracer would accept what the -// metrics exporter silently misreads, which is why the metrics call refuses it: -// both signals follow the one variable, and neither is pointed at a default. -func TestAValueWoolAcceptsAsHostPortIsRefusedByTheMetricsExporterGuard(t *testing.T) { - restoreTracing(t) - isolateOTLPEnvironment(t) - t.Setenv(otlpEndpointVariable, "collector.example:4317") - - provider, err := enableTracing(t.Context(), false) - require.NoError(t, err) - require.NotNil(t, provider) - shutdown(t, provider) - - metrics, err := enableOTELMetrics(t.Context()) - require.ErrorContains(t, err, "http:// or https://") - require.Nil(t, metrics) -} - -type traceCapture struct { +type sdkTraceCapture struct { collectortracev1.UnimplementedTraceServiceServer requests chan *collectortracev1.ExportTraceServiceRequest } -func (c *traceCapture) Export( - _ context.Context, - request *collectortracev1.ExportTraceServiceRequest, -) (*collectortracev1.ExportTraceServiceResponse, error) { - c.requests <- request +func (capture *sdkTraceCapture) Export(_ context.Context, request *collectortracev1.ExportTraceServiceRequest) (*collectortracev1.ExportTraceServiceResponse, error) { + capture.requests <- request return &collectortracev1.ExportTraceServiceResponse{}, nil } - -// startTraceCapture serves an in-process OTLP/gRPC trace receiver on loopback. -func startTraceCapture(t *testing.T) (string, *traceCapture) { - t.Helper() - listener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - capture := &traceCapture{requests: make(chan *collectortracev1.ExportTraceServiceRequest, 4)} - server := grpc.NewServer() - collectortracev1.RegisterTraceServiceServer(server, capture) - go func() { _ = server.Serve(listener) }() - t.Cleanup(func() { - server.Stop() - _ = listener.Close() - }) - return listener.Addr().String(), capture -} - -func shutdown(t *testing.T, provider interface{ Shutdown(context.Context) error }) { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - require.NoError(t, provider.Shutdown(ctx)) -} diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index b6c94c538..2eedf0c5a 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -53,7 +53,20 @@ func init() { WithWork(doWork) } -func doWork(ctx context.Context) (Clean, error) { +func doWork(ctx context.Context) (clean Clean, err error) { + var otelMetricProvider *otelMetrics + // SDK Init owns tracing. Stop telemetry exporters on startup errors too, + // before a normal cleanup function can be returned. + defer func() { + if err != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if otelMetricProvider != nil { + _ = otelMetricProvider.Shutdown(shutdownCtx) + } + _ = codefly.ShutdownTelemetry(shutdownCtx) + } + }() // `app diagnose …` answers and exits; it never starts the service. if code, ran := runRequestedDiagnostic(os.Args[1:]); ran { os.Exit(code) @@ -91,30 +104,13 @@ func doWork(ctx context.Context) (Clean, error) { return nil, err } - // Traces and metrics go to the collector OTEL_EXPORTER_OTLP_ENDPOINT names, which - // the platform delivers when the cell has one this workload may reach. With none - // delivered the process exports nothing over OTLP; a local run's traces go to - // wool's own stdout tracer instead. wool/otel reads and validates the variable, - // so a value it refuses stops the process here with its reason. - otelProvider, err := enableTracing(ctx, codefly.IsLocal()) - if err != nil { - return nil, fmt.Errorf("configure OTEL tracing: %w", err) - } - var otelMetricProvider *otelMetrics if otlpEndpointConfigured() { metricProvider, oerr := enableOTELMetrics(ctx) if oerr != nil { - if otelProvider != nil { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - _ = otelProvider.Shutdown(shutdownCtx) - cancel() - } - return nil, fmt.Errorf("configure OTEL metrics: %w", oerr) + w.Warn("OTEL metrics exporter could not be built; service continues without metrics export", wool.ErrField(oerr)) + } else { + otelMetricProvider = metricProvider } - otelMetricProvider = metricProvider - w.Info("OTEL export enabled: traces and metrics go to the collector at " + otlpEndpointVariable) - } else { - w.Info("OTEL export disabled: " + otlpEndpointVariable + " is not set, so nothing is exported over OTLP") } store, err := infra.NewPostgresStore(ctx) @@ -1444,15 +1440,10 @@ func doWork(ctx context.Context) (Clean, error) { sw.Info("closing store") store.Close() sw.Info("store closed") - if otelProvider != nil { - sw.Info("flushing OTEL provider") - // Bounded shutdown — exporter has its own flush window - // (otlptrace's batcher). 5s is a sensible cap. - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := otelProvider.Shutdown(shutdownCtx); err != nil { - sw.Warn("OTEL shutdown failed", wool.ErrField(err)) - } + traceShutdownCtx, cancelTraceShutdown := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelTraceShutdown() + if err := codefly.ShutdownTelemetry(traceShutdownCtx); err != nil { + sw.Warn("OTEL shutdown failed", wool.ErrField(err)) } if otelMetricProvider != nil { sw.Info("flushing OTEL metrics provider") diff --git a/module/services/accounts/service.codefly.yaml b/module/services/accounts/service.codefly.yaml index 0fc156bd9..2af0b91f7 100644 --- a/module/services/accounts/service.codefly.yaml +++ b/module/services/accounts/service.codefly.yaml @@ -40,7 +40,6 @@ workspace-configuration-dependencies: - internal-auth - internal-transport - key-service - - observability - product-analytics - security - vault diff --git a/module/services/auth-gateway/code/go.mod b/module/services/auth-gateway/code/go.mod index 1ca8eb56e..93d99d89f 100644 --- a/module/services/auth-gateway/code/go.mod +++ b/module/services/auth-gateway/code/go.mod @@ -6,7 +6,7 @@ require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17 - github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 + github.com/codefly-dev/sdk-go v0.4.1 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 github.com/envoyproxy/go-control-plane/envoy v1.39.0 github.com/golang-jwt/jwt/v5 v5.3.1 @@ -70,7 +70,6 @@ require ( github.com/google/go-querystring v1.2.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/kr/text v0.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect diff --git a/module/services/auth-gateway/code/go.sum b/module/services/auth-gateway/code/go.sum index 3dcd3105f..9c9a03077 100644 --- a/module/services/auth-gateway/code/go.sum +++ b/module/services/auth-gateway/code/go.sum @@ -48,15 +48,14 @@ github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1 github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17 h1:ppOzy5ops7mEO9PApDfIcAiUBNy4z1MHOf3UJ8xXNNw= github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17/go.mod h1:OxvwxMdo3HD190xFiMOPHtUsBC7/7Jt1evsEts/2HRs= -github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 h1:hi+KhotTLMOJ/PUSzVO/Axfho7yZ2UOjrCFfrLI1EKU= -github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458/go.mod h1:Qg8SMzZxKSllwvZwShMrWgSetBVMjxP0s17mwWHWjk0= +github.com/codefly-dev/sdk-go v0.4.1 h1:WUKYWxXfojTr88SkLtq0T8aqpV0o5pR/muUUcsUUlzk= +github.com/codefly-dev/sdk-go v0.4.1/go.mod h1:zr9ts3kkWNv2XEFUXZB+ydfW6x5b6/NbUvBm4KVYNqs= github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 h1:wBOL7QsuoC0HsS43gsH2+tpsipk4os077C2VUDkMUHg= github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458/go.mod h1:hwtrdGtgh8i2xzjgihyd4wY2u+UK7nHQz30OGKL0n+M= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/module/services/auth-gateway/code/main.go b/module/services/auth-gateway/code/main.go index bd9330f5e..96bbd6bc9 100644 --- a/module/services/auth-gateway/code/main.go +++ b/module/services/auth-gateway/code/main.go @@ -34,6 +34,14 @@ func main() { panic(fmt.Sprintf("codefly init failed: %v", err)) } + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := codefly.ShutdownTelemetry(shutdownCtx); err != nil { + log.Printf("shutdown: OTEL tracing: %v", err) + } + }() + // Refuse before anything is served: a deployed gateway holding a shipped // placeholder, or a perimeter credential too short to be one, decides // perimeter membership on a published value. @@ -63,29 +71,12 @@ func main() { panic(fmt.Sprintf("Codefly workload endpoint is unavailable: %v", err)) } workloadPort := workloadNet.Port - // Traces and metrics go to the collector OTEL_EXPORTER_OTLP_ENDPOINT names, which - // the platform delivers when the cell has one this workload may reach. With none - // delivered the process exports nothing over OTLP; a local run's traces go to - // wool's own stdout tracer instead. wool/otel reads and validates the variable, - // so a value it refuses stops the process here with its reason. - otelProvider, err := enableTracing(ctx, codefly.IsLocal()) - if err != nil { - panic(fmt.Sprintf("configure OTEL tracing: %v", err)) - } var otelMetricProvider *otelMetrics if otlpEndpointConfigured() { otelMetricProvider, err = enableOTELMetrics(ctx) if err != nil { - if otelProvider != nil { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - _ = otelProvider.Shutdown(shutdownCtx) - cancel() - } - panic(fmt.Sprintf("configure OTEL metrics: %v", err)) + log.Printf("OTEL metrics exporter could not be built; service continues without metrics export: %v", err) } - log.Print("OTEL export enabled: traces and metrics go to the collector at " + otlpEndpointVariable) - } else { - log.Print("OTEL export disabled: " + otlpEndpointVariable + " is not set, so nothing is exported over OTLP") } defer func() { shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -95,11 +86,6 @@ func main() { log.Printf("shutdown: OTEL metrics: %v", err) } } - if otelProvider != nil { - if err := otelProvider.Shutdown(shutdownCtx); err != nil { - log.Printf("shutdown: OTEL tracing: %v", err) - } - } }() // Upstream HTTP URLs used by the gateway listener. diff --git a/module/services/auth-gateway/code/telemetry.go b/module/services/auth-gateway/code/telemetry.go index f25d0d03e..74ba1ed51 100644 --- a/module/services/auth-gateway/code/telemetry.go +++ b/module/services/auth-gateway/code/telemetry.go @@ -3,13 +3,11 @@ package main import ( "context" "errors" - "fmt" "net/url" "os" "strings" "github.com/codefly-dev/core/wool" - wooltel "github.com/codefly-dev/core/wool/otel" codefly "github.com/codefly-dev/sdk-go" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/sdk/resource" @@ -34,34 +32,10 @@ func otlpEndpointConfigured() bool { return strings.TrimSpace(os.Getenv(otlpEndpointVariable)) != "" } -// enableTracing registers wool's tracer: OTLP/gRPC to the collector when the -// environment names one, with wool's own stdout tracer in its place on a local -// run that names none, and no tracer at all otherwise. A nil provider means -// nothing was registered. -// -// wool/otel owns the endpoint: it reads the variable, so a value it refuses (no -// scheme, no port, credentials in the URL) is returned as its error and nothing is -// registered. -func enableTracing(ctx context.Context, local bool) (*wooltel.Provider, error) { - configured := otlpEndpointConfigured() - if !configured && !local { - return nil, nil - } - res, err := currentTelemetryResource(ctx) - if err != nil { - return nil, fmt.Errorf("resolve telemetry resource: %w", err) - } - options := []wooltel.Option{wooltel.WithServiceName(telemetryServiceName(res))} - if !configured { - options = append(options, wooltel.WithStdout()) - } - return wooltel.Enable(options...) -} - // requireMetricsEndpoint refuses what the metrics exporter cannot read. It reads // the variable only as an http:// or https:// URL, and on anything else keeps its // default and dials localhost:4317 without saying so, so a value of another shape -// (wool still accepts a bare host:port for traces) stops the process here. +// is rejected here instead of creating a metrics exporter at a default address. func requireMetricsEndpoint() error { if !otlpEndpointConfigured() { return errors.New("no " + otlpEndpointVariable + " to export metrics to") @@ -73,7 +47,7 @@ func requireMetricsEndpoint() error { return nil } -// telemetryResource is the one resource both signals carry. service.name is +// telemetryResource builds the metrics resource. service.name is // whatever the environment says — OTEL_SERVICE_NAME, or service.name in // OTEL_RESOURCE_ATTRIBUTES — and otherwise the service identity the Codefly SDK // holds. No name is typed here: a literal would override the environment, so the @@ -111,11 +85,3 @@ func codeflyServiceName(ctx context.Context) string { // An identity with no module reads `/service`; with neither, `/`. return strings.Trim(strings.TrimSpace(source.Unique), "/") } - -// telemetryServiceName is the service.name the resource resolved to. -func telemetryServiceName(res *resource.Resource) string { - if value, ok := res.Set().Value(attribute.Key("service.name")); ok { - return value.AsString() - } - return "" -} diff --git a/module/services/auth-gateway/code/telemetry_test.go b/module/services/auth-gateway/code/telemetry_test.go index 07eb32cf3..2372a01cd 100644 --- a/module/services/auth-gateway/code/telemetry_test.go +++ b/module/services/auth-gateway/code/telemetry_test.go @@ -3,16 +3,18 @@ package main import ( "context" "net" - "os" "strings" "testing" "time" - "github.com/codefly-dev/core/wool" - "github.com/stretchr/testify/require" + "github.com/codefly-dev/core/resources" + codefly "github.com/codefly-dev/sdk-go" "go.opentelemetry.io/otel" collectortracev1 "go.opentelemetry.io/proto/otlp/collector/trace/v1" "google.golang.org/grpc" + + "github.com/codefly-dev/core/wool" + "github.com/stretchr/testify/require" ) // isolateOTLPEnvironment clears every OTLP variable the exporters read, so what a @@ -44,32 +46,6 @@ func collectorAt(t *testing.T, scheme, hostPort string) { t.Setenv(otlpEndpointVariable, scheme+"://"+hostPort) } -// shippedObservabilityProfile is what the module's own `observability` profile -// contributes to the group, read from the file it ships. -func shippedObservabilityProfile(t *testing.T) map[string]string { - t.Helper() - contents, err := os.ReadFile("../../../configurations/local/observability.env") - require.NoError(t, err) - values := map[string]string{} - for _, line := range strings.Split(string(contents), "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - key, value, ok := strings.Cut(line, "=") - require.True(t, ok) - values[key] = value - } - return values -} - -// Module defaults also reach deployed cells, so the profile names no collector: -// the group exists (a composing workspace inherits a group from the key it -// assigns), and the one variable it assigns is empty, which is no endpoint. -func TestShippedObservabilityProfileNamesNoCollector(t *testing.T) { - require.Equal(t, map[string]string{otlpEndpointVariable: ""}, shippedObservabilityProfile(t)) -} - func TestOTLPEndpointConfigured(t *testing.T) { isolateOTLPEnvironment(t) require.False(t, otlpEndpointConfigured(), "unset") @@ -86,7 +62,8 @@ func TestTelemetryResourceServiceName(t *testing.T) { t.Helper() res, err := telemetryResource(t.Context(), identity) require.NoError(t, err) - return telemetryServiceName(res) + value, _ := res.Set().Value("service.name") + return value.AsString() } t.Run("falls back to the Codefly service identity", func(t *testing.T) { @@ -124,150 +101,56 @@ func TestCodeflyServiceNameIsTheSDKProviderIdentity(t *testing.T) { require.Empty(t, codeflyServiceName(t.Context()), "no provider: the SDK was not initialised") } -func restoreTracing(t *testing.T) { - t.Helper() - previous := otel.GetTracerProvider() +// SDK Init must deliver a real span with the supplied service identity; its +// public shutdown flushes the batch before the process exits. +func TestSDKExportsTracesAndFlushesOnShutdown(t *testing.T) { + isolateOTLPEnvironment(t) + t.Setenv(resources.ModulePrefix, "saas-starter") + t.Setenv(resources.ServicePrefix, "auth-gateway") + t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") + previousTracer := otel.GetTracerProvider() + previousTelemetry := wool.GetTelemetry() t.Cleanup(func() { - wool.RegisterTelemetry(nil) - otel.SetTracerProvider(previous) + _ = codefly.ShutdownTelemetry(context.Background()) + otel.SetTracerProvider(previousTracer) + wool.RegisterTelemetry(previousTelemetry) }) -} - -// With no endpoint delivered the process registers no OTLP exporter: nothing -// is exported, and nothing dials a default address. -func TestEnableTracingWithoutAnEndpointRegistersNothingOutsideALocalRun(t *testing.T) { - restoreTracing(t) - isolateOTLPEnvironment(t) - provider, err := enableTracing(t.Context(), false) + listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) - require.Nil(t, provider) - require.False(t, wool.TelemetryEnabled()) -} - -// A local run with no endpoint keeps wool's own stdout tracer, which exports -// nothing over OTLP. -func TestEnableTracingWithoutAnEndpointUsesWoolsStdoutTracerInALocalRun(t *testing.T) { - restoreTracing(t) - isolateOTLPEnvironment(t) - provider, err := enableTracing(t.Context(), true) + capture := &sdkTraceCapture{requests: make(chan *collectortracev1.ExportTraceServiceRequest, 1)} + server := grpc.NewServer() + collectortracev1.RegisterTraceServiceServer(server, capture) + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { server.Stop(); _ = listener.Close() }) + t.Setenv(otlpEndpointVariable, "http://"+listener.Addr().String()) + _, err = codefly.Init(t.Context()) require.NoError(t, err) - require.NotNil(t, provider) - require.True(t, wool.TelemetryEnabled()) - shutdown(t, provider) -} - -// The endpoint decides, not where the process runs: a pod deployed into an -// environment named `local` that is handed a collector exports to it, rather -// than falling back to the stdout tracer. -func TestEnableTracingExportsToTheDeliveredEndpointWhetherOrNotTheRunIsLocal(t *testing.T) { - for name, local := range map[string]bool{"deployed": false, "local environment": true} { - t.Run(name, func(t *testing.T) { - restoreTracing(t) - t.Setenv("OTEL_SERVICE_NAME", "named-by-the-platform") - t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") - endpoint, capture := startTraceCapture(t) - collectorAt(t, "http", endpoint) - provider, err := enableTracing(t.Context(), local) - require.NoError(t, err) - require.NotNil(t, provider) - require.True(t, wool.TelemetryEnabled()) - - _, span := otel.Tracer("test").Start(t.Context(), "operation") - span.End() - shutdown(t, provider) // flushes the batch - - select { - case request := <-capture.requests: - var name string - for _, resourceSpans := range request.GetResourceSpans() { - for _, attribute := range resourceSpans.GetResource().GetAttributes() { - if attribute.GetKey() == "service.name" { - name = attribute.GetValue().GetStringValue() - } - } - } - require.Equal(t, "named-by-the-platform", name) - case <-time.After(5 * time.Second): - t.Fatal("timed out waiting for OTLP spans") + _, span := otel.Tracer("test").Start(t.Context(), "operation") + span.End() + shutdownCtx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + require.NoError(t, codefly.ShutdownTelemetry(shutdownCtx)) + select { + case request := <-capture.requests: + require.Len(t, request.ResourceSpans, 1) + for _, attribute := range request.ResourceSpans[0].Resource.Attributes { + if attribute.Key == "service.name" { + require.Equal(t, "named-by-the-platform", attribute.Value.GetStringValue()) + return } - }) - } -} - -// core's wool/otel owns what an endpoint may be, and a value it refuses stops -// the process with its reason and registers nothing — never a tracer pointed at a -// default address. -func TestEnableTracingReturnsWhatWoolRefusesAndRegistersNothing(t *testing.T) { - for name, value := range map[string]string{ - "no port": "http://otel-collector.otel-collector.svc.cluster.local", - "userinfo": "http://user:secret-pass@collector.example:4317", - } { - t.Run(name, func(t *testing.T) { - restoreTracing(t) - isolateOTLPEnvironment(t) - t.Setenv(otlpEndpointVariable, value) - provider, err := enableTracing(t.Context(), false) - require.Error(t, err) - require.NotContains(t, err.Error(), "secret-pass", "the refusal never echoes the URL") - require.Nil(t, provider) - require.False(t, wool.TelemetryEnabled()) - }) + } + t.Fatal("exported trace has no service.name") + case <-time.After(5 * time.Second): + t.Fatal("SDK shutdown did not deliver the span") } } -// wool takes a value that is not an http:// or https:// URL for a bare -// host:port, the shape it accepted before it read URLs, and dials it in -// plaintext. The metrics exporter reads only a URL and, given anything else, -// keeps its default, localhost:4317. So the tracer would accept what the -// metrics exporter silently misreads, which is why the metrics call refuses it: -// both signals follow the one variable, and neither is pointed at a default. -func TestAValueWoolAcceptsAsHostPortIsRefusedByTheMetricsExporterGuard(t *testing.T) { - restoreTracing(t) - isolateOTLPEnvironment(t) - t.Setenv(otlpEndpointVariable, "collector.example:4317") - - provider, err := enableTracing(t.Context(), false) - require.NoError(t, err) - require.NotNil(t, provider) - shutdown(t, provider) - - metrics, err := enableOTELMetrics(t.Context()) - require.ErrorContains(t, err, "http:// or https://") - require.Nil(t, metrics) -} - -type traceCapture struct { +type sdkTraceCapture struct { collectortracev1.UnimplementedTraceServiceServer requests chan *collectortracev1.ExportTraceServiceRequest } -func (c *traceCapture) Export( - _ context.Context, - request *collectortracev1.ExportTraceServiceRequest, -) (*collectortracev1.ExportTraceServiceResponse, error) { - c.requests <- request +func (capture *sdkTraceCapture) Export(_ context.Context, request *collectortracev1.ExportTraceServiceRequest) (*collectortracev1.ExportTraceServiceResponse, error) { + capture.requests <- request return &collectortracev1.ExportTraceServiceResponse{}, nil } - -// startTraceCapture serves an in-process OTLP/gRPC trace receiver on loopback. -func startTraceCapture(t *testing.T) (string, *traceCapture) { - t.Helper() - listener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - capture := &traceCapture{requests: make(chan *collectortracev1.ExportTraceServiceRequest, 4)} - server := grpc.NewServer() - collectortracev1.RegisterTraceServiceServer(server, capture) - go func() { _ = server.Serve(listener) }() - t.Cleanup(func() { - server.Stop() - _ = listener.Close() - }) - return listener.Addr().String(), capture -} - -func shutdown(t *testing.T, provider interface{ Shutdown(context.Context) error }) { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - require.NoError(t, provider.Shutdown(ctx)) -} diff --git a/module/services/auth-gateway/service.codefly.yaml b/module/services/auth-gateway/service.codefly.yaml index d4d55ab22..24c187049 100644 --- a/module/services/auth-gateway/service.codefly.yaml +++ b/module/services/auth-gateway/service.codefly.yaml @@ -34,7 +34,6 @@ workspace-configuration-dependencies: - gateway - gateway-trust - internal-auth - - observability - security endpoints: - name: grpc diff --git a/topology_declarations_test.go b/topology_declarations_test.go index 34f3c4ea0..cac1bc8a0 100644 --- a/topology_declarations_test.go +++ b/topology_declarations_test.go @@ -1,9 +1,11 @@ package main import ( + "errors" "strings" "testing" + "github.com/codefly-dev/core/resources" "gopkg.in/yaml.v3" ) @@ -30,7 +32,7 @@ func TestTopologyRefusesAuthoredConsumersByKeyPresence(t *testing.T) { document := []byte("visibility: internal\n" + key + ": " + value + "\n") for _, endpoint := range []any{&topologyManifestEndpoint{}, &topologyInterface{}} { err := yaml.Unmarshal(document, endpoint) - if err == nil || !strings.Contains(err.Error(), "derived from consumers") { + if !errors.Is(err, resources.ErrInvalidEndpointDeclaration) { t.Fatalf("%T %s=%s: %v", endpoint, key, value, err) } } diff --git a/topology_manifests.go b/topology_manifests.go index fbe9da781..d6d98007f 100644 --- a/topology_manifests.go +++ b/topology_manifests.go @@ -9,10 +9,10 @@ import ( "os" "path/filepath" "sort" - "strings" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/core/network" + "github.com/codefly-dev/core/resources" "gopkg.in/yaml.v3" ) @@ -295,46 +295,13 @@ func loadDeployJobs(moduleDir string) ([]topologyDeployJob, error) { return manifest.DeployJobs, nil } -type topologyManifestEndpoint struct { - Name string `yaml:"name"` - Visibility string `yaml:"visibility"` - API string `yaml:"api"` - Location string `yaml:"location"` -} - -func (endpoint *topologyManifestEndpoint) UnmarshalYAML(node *yaml.Node) error { - if err := refuseAuthoredConsumers(node); err != nil { - return err - } - type plain topologyManifestEndpoint - return node.Decode((*plain)(endpoint)) -} +type topologyManifestEndpoint = resources.Endpoint func (endpoint *topologyInterface) UnmarshalYAML(node *yaml.Node) error { - if err := refuseAuthoredConsumers(node); err != nil { + var declaration resources.InterfaceEndpoint + if err := node.Decode(&declaration); err != nil { return err } - type plain topologyInterface - return node.Decode((*plain)(endpoint)) -} - -// Judge key presence before decoding: null and alternate spellings cannot -// silently erase a forbidden grant. This matches Core's declaration boundary. -func refuseAuthoredConsumers(node *yaml.Node) error { - if node.Kind != yaml.MappingNode { - return nil - } - for i := 0; i+1 < len(node.Content); i += 2 { - key := node.Content[i].Value - var canonical strings.Builder - for _, r := range strings.ToLower(key) { - if r >= 'a' && r <= 'z' { - canonical.WriteRune(r) - } - } - if canonical.String() == "allowmodules" { - return fmt.Errorf("authors %q: endpoint access is derived from consumers' declared dependencies", key) - } - } + endpoint.Service, endpoint.Endpoint, endpoint.Visibility = declaration.Service, declaration.Endpoint, declaration.Visibility return nil } From 6f5e391d6fa0b6431dca8542cdd615aade6e29cf Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:10:30 +0000 Subject: [PATCH 16/20] fix(telemetry): remove retired workspace configuration link --- configurations/local/observability.env | 1 - 1 file changed, 1 deletion(-) delete mode 120000 configurations/local/observability.env diff --git a/configurations/local/observability.env b/configurations/local/observability.env deleted file mode 120000 index 717462ff9..000000000 --- a/configurations/local/observability.env +++ /dev/null @@ -1 +0,0 @@ -../../module/configurations/local/observability.env \ No newline at end of file From 4c9a9a69e4d2ad17d847ff081f1a90e3b9c168b2 Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:22:09 +0000 Subject: [PATCH 17/20] test(telemetry): isolate SDK initialization from configuration tests --- module/services/accounts/code/telemetry_test.go | 13 +++++++++++++ module/services/auth-gateway/code/telemetry_test.go | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/module/services/accounts/code/telemetry_test.go b/module/services/accounts/code/telemetry_test.go index b0611abc8..2e5a25214 100644 --- a/module/services/accounts/code/telemetry_test.go +++ b/module/services/accounts/code/telemetry_test.go @@ -3,6 +3,8 @@ package main import ( "context" "net" + "os" + "os/exec" "strings" "testing" "time" @@ -104,6 +106,17 @@ func TestCodeflyServiceNameIsTheSDKProviderIdentity(t *testing.T) { // SDK Init must deliver a real span with the supplied service identity; its // public shutdown flushes the batch before the process exits. func TestSDKExportsTracesAndFlushesOnShutdown(t *testing.T) { + // SDK Init snapshots runtime configuration and consumer identity globally. + // Run the real SDK lifecycle in a child, as the diagnostic tests do, so + // later configuration tests can still vary their own process environment. + const childMarker = "SDK_TELEMETRY_TEST_CHILD" + if os.Getenv(childMarker) != "1" { + child := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestSDKExportsTracesAndFlushesOnShutdown$", "-test.timeout=15s") + child.Env = append(os.Environ(), childMarker+"=1") + output, err := child.CombinedOutput() + require.NoError(t, err, "SDK trace exporter child: %s", output) + return + } isolateOTLPEnvironment(t) t.Setenv(resources.ModulePrefix, "saas-starter") t.Setenv(resources.ServicePrefix, "accounts") diff --git a/module/services/auth-gateway/code/telemetry_test.go b/module/services/auth-gateway/code/telemetry_test.go index 2372a01cd..f384fbae8 100644 --- a/module/services/auth-gateway/code/telemetry_test.go +++ b/module/services/auth-gateway/code/telemetry_test.go @@ -3,6 +3,8 @@ package main import ( "context" "net" + "os" + "os/exec" "strings" "testing" "time" @@ -104,6 +106,17 @@ func TestCodeflyServiceNameIsTheSDKProviderIdentity(t *testing.T) { // SDK Init must deliver a real span with the supplied service identity; its // public shutdown flushes the batch before the process exits. func TestSDKExportsTracesAndFlushesOnShutdown(t *testing.T) { + // SDK Init snapshots runtime configuration and consumer identity globally. + // Run the real SDK lifecycle in a child, as the diagnostic tests do, so + // later configuration tests can still vary their own process environment. + const childMarker = "SDK_TELEMETRY_TEST_CHILD" + if os.Getenv(childMarker) != "1" { + child := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestSDKExportsTracesAndFlushesOnShutdown$", "-test.timeout=15s") + child.Env = append(os.Environ(), childMarker+"=1") + output, err := child.CombinedOutput() + require.NoError(t, err, "SDK trace exporter child: %s", output) + return + } isolateOTLPEnvironment(t) t.Setenv(resources.ModulePrefix, "saas-starter") t.Setenv(resources.ServicePrefix, "auth-gateway") From f13ed4e3a7a5552e4360fafdbbdf3e29435d4f87 Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:29:02 +0000 Subject: [PATCH 18/20] fix: consume environment-wide telemetry delivery --- .../skills/run-the-starter-locally/SKILL.md | 4 ++-- DOGFOODING.md | 9 ++++----- LOCAL_DOGFOODING.md | 8 ++++---- MODULE.md | 6 +++--- module/AGENTS.md | 12 +++++------ module/DEPLOYMENT_TOPOLOGY.md | 19 ++++++++++-------- module/FEATURES.md | 20 ++++++++++--------- module/module.package.codefly.yaml | 2 +- .../accounts/code/telemetry_metrics.go | 4 ++-- .../services/accounts/code/telemetry_test.go | 5 ++--- .../auth-gateway/code/telemetry_metrics.go | 4 ++-- .../auth-gateway/code/telemetry_test.go | 5 ++--- scripts/ci/ci-performance.test.mjs | 7 ++++--- scripts/ci/install-codefly.sh | 7 ++++++- 14 files changed, 59 insertions(+), 53 deletions(-) diff --git a/.claude/skills/run-the-starter-locally/SKILL.md b/.claude/skills/run-the-starter-locally/SKILL.md index 099fc27e9..add8f24a5 100644 --- a/.claude/skills/run-the-starter-locally/SKILL.md +++ b/.claude/skills/run-the-starter-locally/SKILL.md @@ -27,8 +27,8 @@ codefly run service --fixture dev-admin select export: `OTEL_EXPORTER_OTLP_ENDPOINT` names the collector, with `OTEL_SERVICE_NAME` and `OTEL_RESOURCE_ATTRIBUTES` supplying identity. CLIs implementing [CLI #1097](https://github.com/codefly-dev/cli/pull/1097) - also deliver these for every service through an environment's `telemetry:` - block; scoped platform `service-config` delivery needs no such block. + deliver these for every service through an environment's optional `telemetry:` + block, pointing at the HTTPS operations intake. Without an endpoint, SDK Init enables no tracing, including for `local`. No module configuration group is required. diff --git a/DOGFOODING.md b/DOGFOODING.md index 31690ee8a..a7b4cf83e 100644 --- a/DOGFOODING.md +++ b/DOGFOODING.md @@ -219,11 +219,10 @@ the same Codefly-managed graph. For each of `email` (Resend), `configurations/local-dogfood/.env.example` and `.secret.env.example` to their Git-ignored names and fill them in ([LOCAL_DOGFOODING.md](./LOCAL_DOGFOODING.md#configure-providers-through-codefly)). -Operational telemetry has no configuration group. Supply the standard -`OTEL_EXPORTER_OTLP_ENDPOINT` process variable through scoped platform -`service-config` delivery. CLIs implementing -[CLI #1097](https://github.com/codefly-dev/cli/pull/1097) also support the -environment's optional `telemetry:` block. +Operational telemetry has no configuration group. Use the environment's optional +`telemetry:` block with the HTTPS operations intake. +[CLI #1097](https://github.com/codefly-dev/cli/pull/1097) delivers the standard +endpoint, service identity and resource variables to every service. With no endpoint, no traces or metrics are exported, including native local runs. Turnstile's published test keys drive the three abuse-protection cases: diff --git a/LOCAL_DOGFOODING.md b/LOCAL_DOGFOODING.md index 5cbffb424..4dbec8d8b 100644 --- a/LOCAL_DOGFOODING.md +++ b/LOCAL_DOGFOODING.md @@ -55,10 +55,10 @@ Validate the result with `codefly doctor workspace --env local-dogfood`. Operational telemetry uses standard process variables rather than a configuration group. With no `OTEL_EXPORTER_OTLP_ENDPOINT`, the SDK exports no traces in any -environment, and the services build no metrics exporter. To export, deliver the -endpoint as scoped platform `service-config`. CLIs implementing -[CLI #1097](https://github.com/codefly-dev/cli/pull/1097) also support the -environment's optional `telemetry:` block, which applies to every service. SDK exporter failures are +environment, and the services build no metrics exporter. To export, use the +environment's optional `telemetry:` block with the HTTPS operations intake. +[CLI #1097](https://github.com/codefly-dev/cli/pull/1097) delivers standard OTEL +variables to every service. SDK exporter failures are logged without stopping the service; logs always go to stdout. `email` is required because this profile is not the `local` environment, and diff --git a/MODULE.md b/MODULE.md index 0f7125a30..456abb2b1 100644 --- a/MODULE.md +++ b/MODULE.md @@ -100,7 +100,7 @@ capability; it does not claim that every planned provider is released yet. | Product analytics | PostHog | The `product-analytics@1` browser/server APIs may capture registered events and perform consent-gated identify, alias, organization grouping, and privacy suppression. An event may carry a variant already evaluated by Unleash for experiment analysis. | Flag definition or evaluation, exception capture, and traces, metrics, or logs | | Session replay | PostHog | A browser-only recorder may start after separate replay consent and redaction policy resolve to allow. It has no server SDK or implicit analytics-consent fallback. | Flags, errors, and APM signals | | Error tracking | Sentry | Browser/server exception events, release/environment tags, source-map upload, and the error-issue workflow. Provisioning credentials remain build/provider-only. | Performance transactions, profiles, replay, logs, metrics, and feature flags | -| APM traces, metrics, and logs | SigNoz | Applications use OpenTelemetry SDKs and export traces and metrics over OTLP/gRPC to the cell's collector, whose address the platform delivers as the standard `OTEL_EXPORTER_OTLP_ENDPOINT` variable (or delivers nothing, and then nothing is exported); logs go to stdout. SigNoz receives those signals as the OTLP backend. | Product analytics, replay, feature flags, and the Sentry error-issue contract | +| APM traces, metrics, and logs | SigNoz | Applications use OpenTelemetry SDKs and export traces and metrics over OTLP/gRPC to the HTTPS operations intake, whose address the environment's `telemetry:` declaration delivers as the standard `OTEL_EXPORTER_OTLP_ENDPOINT` variable (or delivers nothing, and then nothing is exported); logs go to stdout. SigNoz receives those signals as the OTLP backend. | Product analytics, replay, feature flags, and the Sentry error-issue contract | Provider manifests are allowlists. `provider-posthog` may project only `product-analytics@1`; its browser initialization must disable flag remote @@ -112,8 +112,8 @@ Sentry trace sampling at zero and installs no Sentry tracing integration. dashboards and alerts but projects no application runtime configuration. In particular, dashboard provisioning is not an application telemetry contract: the application OTLP endpoint remains instrumentation configuration, delivered -as the standard `OTEL_EXPORTER_OTLP_ENDPOINT` variable when the cell has a -collector. +as the standard `OTEL_EXPORTER_OTLP_ENDPOINT` variable through the +environment's optional `telemetry:` declaration. The starter keeps the capability configurations independent. Selecting PostHog never selects flags, errors, or observability; selecting Sentry never diff --git a/module/AGENTS.md b/module/AGENTS.md index afdd6fe4c..88a6215b7 100644 --- a/module/AGENTS.md +++ b/module/AGENTS.md @@ -105,13 +105,11 @@ Postgres 0.0.144 and Vault 0.0.42, which include their owners' outcome fixes. An empty legacy response is not evidence that tests passed; do not bypass that check or disable the service's test phase to accept it. -Lifecycle CI selects the exact CLI source candidate in -`scripts/ci/install-codefly.sh` for every planner/executor job. It recognises -the CLI-owned `artifact-cache` group without an authored default. This uses -the canonical versioned Go install entrypoint; it does not require a CLI -release for development qualification. The installer refuses release tags -while the source candidate is selected. Restore a qualified released pin -before publishing the host. +CI selects released CLI v0.1.197 in `scripts/ci/install-codefly.sh` for every +planner/executor job. This release includes the merged lifecycle/artifact-cache +implementation and environment-wide standard OTEL delivery from the optional +`telemetry:` declaration. The historical source candidate remains available only +for explicit development qualification; release tags refuse source candidates. Accounts and auth-gateway use released SDK v0.4.1; other services retain their own released pins. diff --git a/module/DEPLOYMENT_TOPOLOGY.md b/module/DEPLOYMENT_TOPOLOGY.md index 6a5707b87..51ab49cab 100644 --- a/module/DEPLOYMENT_TOPOLOGY.md +++ b/module/DEPLOYMENT_TOPOLOGY.md @@ -112,14 +112,17 @@ is tracked by `P1-CI-004`. The generated Codefly dependency declarations contain exact endpoint references. The module owns no collector: accounts and auth-gateway export traces -and metrics to the cell's, whose address the platform delivers as the standard -`OTEL_EXPORTER_OTLP_ENDPOINT` variable when the cell has a collector they may -reach, and delivers nothing when it does not. Neither service needs public egress -to reach a telemetry backend and no product configuration owns a collector port. -The generated NetworkPolicy remains the hard endpoint/port enforcement boundary -for the services the module declares, and it grants no egress to the cell's -collector: that collector's namespace and port are not part of the environment -declaration the generators read, so the module renders no rule for them. +and metrics to the HTTPS operations intake. The environment's optional +`telemetry:` declaration lets the CLI deliver the standard OTEL variables to +every service. Without an endpoint the services export nothing. + +The generated NetworkPolicy remains the endpoint/port enforcement boundary for +services the module declares. The module does not derive an operations-intake +egress rule from the environment telemetry declaration. Auth-gateway has no +public egress; an external HTTPS intake therefore requires exact destination +access in the owning deployment policy, as well as the mesh's external egress +permission and DNS resolution. Successful new-environment export remains a +rollout prerequisite; this change does not broaden the module's public egress. The AWS overlay replaces stateful services with managed dependencies. A pod-selector rule cannot authorize an RDS, ElastiCache, external Vault, or S3 diff --git a/module/FEATURES.md b/module/FEATURES.md index 2ca7e7c22..f8b6d8f32 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -558,8 +558,8 @@ extraction contracts. |----------------|--------|-----------------------------------------------------------------| | Structured logs| ✅ | `wool` everywhere; user/org/action context auto-attached | | Audit trail | ✅ | Separate from app logs; queryable | -| Metrics | ✅ | Job-worker OTel instruments, durable queue projections, and Go runtime and request metrics pushed over OTLP to the cell's collector | -| Tracing | ✅ | Accounts and auth-gateway export OTLP to the cell's collector, whose address the platform delivers as `OTEL_EXPORTER_OTLP_ENDPOINT`, for the designated SigNoz backend | +| Metrics | ✅ | Job-worker OTel instruments, durable queue projections, and Go runtime and request metrics pushed over OTLP to the HTTPS operations intake | +| Tracing | ✅ | Accounts and auth-gateway export OTLP to the HTTPS operations intake, whose address the platform delivers as `OTEL_EXPORTER_OTLP_ENDPOINT`, for the designated SigNoz backend | | Error tracking | ✅ | Explicit fail-closed Sentry mode for server/browser errors; trace sampling is fixed at zero | | Dashboards | 🟡 | Versioned provider-neutral business dashboard pack; [SigNoz provisioning remains unsupported pending a pinned service qualification](SIGNOZ_PROVISIONING.md) | @@ -750,7 +750,7 @@ Environment variables consumed by the api: | `POSTHOG_API_HOST` | Separate PostHog management/deletion origin | | `ERROR_TRACKING_MODE` | Explicit `disabled` or `sentry`; rejects partial config | | `SENTRY_DSN` | Server Sentry DSN, required in Sentry mode | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | The one telemetry variable, in the process environment: the cell collector's OTLP/gRPC address, delivered by the platform when the cell has a collector the workload may reach. Unset means nothing is exported over OTLP; `http://` is plaintext on the wire because the mesh supplies mTLS, `https://` is TLS | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | The OTLP/gRPC target in the process environment: the operations intake's HTTPS OTLP/gRPC address, delivered from the environment's optional `telemetry:` declaration to every service. Unset means nothing is exported over OTLP; `https://` uses TLS, while `http://` is available for local plaintext receivers | | `ABUSE_PROTECTION_MODE` | Explicit `disabled` or `turnstile` | | `TURNSTILE_SECRET_KEY` | Server-only Siteverify credential | | `TURNSTILE_ALLOWED_HOSTNAMES` | Exact accepted Turnstile response hostnames | @@ -764,18 +764,20 @@ also install an OTLP metrics provider for unsampled request and Go runtime metrics; no scrape endpoint is served. Delivery uses OpenTelemetry process variables, not a module configuration group. -The platform can deliver scoped `service-config` values to admitted workloads; -CLIs implementing [CLI #1097](https://github.com/codefly-dev/cli/pull/1097) -can instead supply the standard variables to every service through the -environment's optional `telemetry:` block. The current lifecycle CLI pin does -not need that block for scoped `service-config` delivery. `OTEL_SERVICE_NAME` -selects trace identity, falling back to the SDK's `module/service` identity. +The environment's optional `telemetry:` declaration is the platform delivery +contract. [CLI #1097](https://github.com/codefly-dev/cli/pull/1097) supplies the +standard endpoint, service identity and resource variables to every service; +services that do not instrument telemetry can ignore them. The endpoint names +the HTTPS operations intake, rather than a collector deployed by this module. +`OTEL_SERVICE_NAME` selects trace identity, falling back to the SDK's +`module/service` identity. Metrics also consume `OTEL_RESOURCE_ATTRIBUTES`. Core currently forwards only `service.name` and `library.name` to traces, so arbitrary resource attributes are not yet attached to trace exports. | Build or reuse | Verified implementation | Decision | | --- | --- | --- | +| Environment delivery (user choice, 2026-10-21) | Merged [CLI #1097](https://github.com/codefly-dev/cli/pull/1097) environment telemetry injection; operations intake owned by infrastructure | Reuse every-service standard OTEL delivery; remove the overlapping scoped in-cell carrier. Released CLI v0.1.197 includes both this delivery and the squash-merged lifecycle implementation. | | Tracing lifecycle and identity | Released SDK v0.4.1 [Init / ShutdownTelemetry](https://github.com/codefly-dev/sdk-go/blob/v0.4.1/codefly.go#L66), using Core wool/otel | Reuse SDK lifecycle; remove per-service tracing bootstrap and its duplicate tests. | | Endpoint declaration validation | Core [Endpoint](https://github.com/codefly-dev/core/blob/v0.18.0/resources/endpoint.go#L134) / [InterfaceEndpoint](https://github.com/codefly-dev/core/blob/v0.18.0/resources/module.go#L69) YAML decoders | Reuse the Core decoders before projecting deployment fields. | | Metrics export and runtime instruments | OpenTelemetry OTLP metrics exporter and runtime instrumentation v0.71.0; Core/SDK provide no metrics bootstrap | Keep service wiring with the standard exporter. The services remain separate Go modules; no additional shared framework is introduced. | diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index a8b4ba95a..5401faf8b 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -100,7 +100,7 @@ migrations: breaking-changes: - Consumers resolve the immutable package into a disposable projection instead of editing Starter-owned files. - "Audit event types are namespaced under `saas.*` (`auth.login` -> `saas.auth.login`). The wire value of `audit_events.event_type`, the `X-Webhook-Event` header, and webhook routing keys all change; migration 116 rewrites stored history and existing subscriptions in place, so a live subscriber keeps firing, but a consumer that re-creates a subscription from a hardcoded pre-namespace name, or emits one through `ModuleEmitAuditEvent`, is now rejected. No alias layer." - - "The telemetry service and its dependency contract are removed. SDK Init exports traces when OTEL_EXPORTER_OTLP_ENDPOINT is delivered; no endpoint means no tracing, including native local runs. Exporter initialization failures are logged without stopping the service. Accounts and auth-gateway push metrics to the same endpoint and no longer serve /metrics. Telemetry uses standard process variables, not the removed observability configuration group. Deliver OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_SERVICE_NAME through scoped service-config or a compatible CLI environment telemetry declaration. Existing consumers of the telemetry service must migrate to the platform collector." + - "The telemetry service and its dependency contract are removed. SDK Init exports traces when OTEL_EXPORTER_OTLP_ENDPOINT is delivered; no endpoint means no tracing, including native local runs. Exporter initialization failures are logged without stopping the service. Accounts and auth-gateway push metrics to the same endpoint and no longer serve /metrics. Telemetry uses standard process variables, not the removed observability configuration group. Deliver standard OTEL variables to every service through the environment telemetry declaration using a compatible CLI. Existing consumers of the telemetry service must migrate to the HTTPS operations intake." reserved-namespaces: - "@codefly/saas-" - "@codefly-dev/saas-" diff --git a/module/services/accounts/code/telemetry_metrics.go b/module/services/accounts/code/telemetry_metrics.go index 38099ddc8..b6f672284 100644 --- a/module/services/accounts/code/telemetry_metrics.go +++ b/module/services/accounts/code/telemetry_metrics.go @@ -37,8 +37,8 @@ func enableOTELMetrics(ctx context.Context) (*otelMetrics, error) { return nil, err } // The exporter reads the collector from OTEL_EXPORTER_OTLP_ENDPOINT itself and - // takes the transport off the URL's scheme: http:// is plaintext on the wire, - // because the mesh supplies mTLS, and https:// is TLS. + // takes transport from the URL scheme: the operations intake uses https:// + // with TLS; http:// remains available for local plaintext receivers. exporter, err := otlpmetricgrpc.New(ctx) if err != nil { return nil, err diff --git a/module/services/accounts/code/telemetry_test.go b/module/services/accounts/code/telemetry_test.go index 2e5a25214..905a868d6 100644 --- a/module/services/accounts/code/telemetry_test.go +++ b/module/services/accounts/code/telemetry_test.go @@ -40,8 +40,7 @@ func isolateOTLPEnvironment(t *testing.T) { } } -// collectorAt is the platform's whole delivery: the one variable, set to the -// collector's address. +// collectorAt configures the endpoint carrier for a loopback export test. func collectorAt(t *testing.T, scheme, hostPort string) { t.Helper() isolateOTLPEnvironment(t) @@ -53,7 +52,7 @@ func TestOTLPEndpointConfigured(t *testing.T) { require.False(t, otlpEndpointConfigured(), "unset") t.Setenv(otlpEndpointVariable, " ") require.False(t, otlpEndpointConfigured(), "a blank value is no endpoint: the metrics exporter trims it to unset") - t.Setenv(otlpEndpointVariable, "http://otel-collector.otel-collector.svc.cluster.local:4317") + t.Setenv(otlpEndpointVariable, "https://telemetry.example.com:443") require.True(t, otlpEndpointConfigured()) } diff --git a/module/services/auth-gateway/code/telemetry_metrics.go b/module/services/auth-gateway/code/telemetry_metrics.go index b30c63352..63610a816 100644 --- a/module/services/auth-gateway/code/telemetry_metrics.go +++ b/module/services/auth-gateway/code/telemetry_metrics.go @@ -39,8 +39,8 @@ func enableOTELMetrics(ctx context.Context) (*otelMetrics, error) { return nil, err } // The exporter reads the collector from OTEL_EXPORTER_OTLP_ENDPOINT itself and - // takes the transport off the URL's scheme: http:// is plaintext on the wire, - // because the mesh supplies mTLS, and https:// is TLS. + // takes transport from the URL scheme: the operations intake uses https:// + // with TLS; http:// remains available for local plaintext receivers. exporter, err := otlpmetricgrpc.New(ctx) if err != nil { return nil, err diff --git a/module/services/auth-gateway/code/telemetry_test.go b/module/services/auth-gateway/code/telemetry_test.go index f384fbae8..d79b341e1 100644 --- a/module/services/auth-gateway/code/telemetry_test.go +++ b/module/services/auth-gateway/code/telemetry_test.go @@ -40,8 +40,7 @@ func isolateOTLPEnvironment(t *testing.T) { } } -// collectorAt is the platform's whole delivery: the one variable, set to the -// collector's address. +// collectorAt configures the endpoint carrier for a loopback export test. func collectorAt(t *testing.T, scheme, hostPort string) { t.Helper() isolateOTLPEnvironment(t) @@ -53,7 +52,7 @@ func TestOTLPEndpointConfigured(t *testing.T) { require.False(t, otlpEndpointConfigured(), "unset") t.Setenv(otlpEndpointVariable, " ") require.False(t, otlpEndpointConfigured(), "a blank value is no endpoint: the metrics exporter trims it to unset") - t.Setenv(otlpEndpointVariable, "http://otel-collector.otel-collector.svc.cluster.local:4317") + t.Setenv(otlpEndpointVariable, "https://telemetry.example.com:443") require.True(t, otlpEndpointConfigured()) } diff --git a/scripts/ci/ci-performance.test.mjs b/scripts/ci/ci-performance.test.mjs index 10d32ec14..3fcbbb93e 100644 --- a/scripts/ci/ci-performance.test.mjs +++ b/scripts/ci/ci-performance.test.mjs @@ -65,7 +65,8 @@ test('the CLI installer retries resets, verifies downloads, and fails after exha // places, and it is what caught this branch moving the installer to 0.1.179 // while this constant still said 0.1.171. Deriving it from the installer would // make the second assertion below tautological and lose that. -const DEFAULT_CODEFLY_VERSION = 'source-5234ce248b092edf1138a16ec4140dfbc8745522'; +const DEFAULT_CODEFLY_VERSION = '0.1.197'; +const HISTORICAL_SOURCE_VERSION = 'source-5234ce248b092edf1138a16ec4140dfbc8745522'; test('source qualification installs the exact owner commit and never falls back or releases it', () => { const dir = mkdtempSync(join(tmpdir(), 'ci-cli-source-')); @@ -73,14 +74,14 @@ test('source qualification installs the exact owner commit and never falls back writeFileSync(join(dir, 'uname'), '#!/bin/sh\nif [ "$1" = -s ]; then echo Linux; else echo x86_64; fi\n', { mode: 0o755 }); writeFileSync(join(dir, 'go'), '#!/bin/sh\nprintf "%s\\n" "$@" > "$RUNNER_TEMP/go-args"\nprintf "%s\\n" "$GOWORK" "$GOBIN" > "$RUNNER_TEMP/go-env"\nexit "${TEST_GO_EXIT:-0}"\n', { mode: 0o755 }); const pathFile = join(dir, 'github-path'); - const env = { ...process.env, PATH: `${dir}:${process.env.PATH}`, CODEFLY_VERSION: DEFAULT_CODEFLY_VERSION, + const env = { ...process.env, PATH: `${dir}:${process.env.PATH}`, CODEFLY_VERSION: HISTORICAL_SOURCE_VERSION, RUNNER_TEMP: dir, GITHUB_PATH: pathFile, GITHUB_REF: 'refs/pull/1/merge' }; const run = overrides => spawnSync('bash', [join(root, 'scripts/ci/install-codefly.sh')], { encoding: 'utf8', env: { ...env, ...overrides }, }); const success = run({}); assert.equal(success.status, 0, success.stderr); - assert.ok(readFileSync(join(dir, 'go-args'), 'utf8').includes(`github.com/codefly-dev/cli/cmd/codefly@${DEFAULT_CODEFLY_VERSION.slice(7)}\n`)); + assert.ok(readFileSync(join(dir, 'go-args'), 'utf8').includes(`github.com/codefly-dev/cli/cmd/codefly@${HISTORICAL_SOURCE_VERSION.slice(7)}\n`)); assert.equal(readFileSync(join(dir, 'go-env'), 'utf8'), `off\n${dir}/codefly-bin\n`); assert.equal(readFileSync(pathFile, 'utf8'), `${dir}/codefly-bin\n`); writeFileSync(pathFile, ''); diff --git a/scripts/ci/install-codefly.sh b/scripts/ci/install-codefly.sh index 06e379c59..95995c614 100644 --- a/scripts/ci/install-codefly.sh +++ b/scripts/ci/install-codefly.sh @@ -2,7 +2,7 @@ # The hosted CI runner is Linux x64. Keep the release and archive digest paired. set -euo pipefail -version="${CODEFLY_VERSION:-source-5234ce248b092edf1138a16ec4140dfbc8745522}" +version="${CODEFLY_VERSION:-0.1.197}" source_revision="" case "${version}" in source-5234ce248b092edf1138a16ec4140dfbc8745522) @@ -63,6 +63,11 @@ case "${version}" in # Previous generator pin; retained for explicit historical qualifications. checksum=6e7666ef095815a77c10fb1644c69e17b0e8869476c18713bfaa496971b5c45f ;; + 0.1.197) + # Includes the merged lifecycle/artifact-cache changes (cli#994) and + # environment-wide standard OTEL delivery (cli#1097). + checksum=f426ca8ad8c13be4a8aba63c3618a866cb35462a9eb08def2a59b4d9b8290827 + ;; 0.1.185) # Released Core v0.18.0 and cli#947: scope-slot delivery, endpoint-served # Runnable derivation, and CLI-owned module contracts.codefly.yaml. From 5adf1cdcc4057a16110024feb4bf0c6292b702e4 Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:32:57 +0000 Subject: [PATCH 19/20] docs: describe existing ambient telemetry transport --- module/DEPLOYMENT_TOPOLOGY.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/module/DEPLOYMENT_TOPOLOGY.md b/module/DEPLOYMENT_TOPOLOGY.md index 51ab49cab..5e700a935 100644 --- a/module/DEPLOYMENT_TOPOLOGY.md +++ b/module/DEPLOYMENT_TOPOLOGY.md @@ -117,12 +117,14 @@ and metrics to the HTTPS operations intake. The environment's optional every service. Without an endpoint the services export nothing. The generated NetworkPolicy remains the endpoint/port enforcement boundary for -services the module declares. The module does not derive an operations-intake -egress rule from the environment telemetry declaration. Auth-gateway has no -public egress; an external HTTPS intake therefore requires exact destination -access in the owning deployment policy, as well as the mesh's external egress -permission and DNS resolution. Successful new-environment export remains a -rollout prerequisite; this change does not broaden the module's public egress. +services the module declares. Auth-gateway has no direct public TCP 443 egress. +The selected ambient waypoint route uses the existing +[`allow-ambient-hbone-transport` policy](../gitops.go#L2255), which admits TCP +15008 transport for every pod without a destination restriction. The route does +not require a new module collector-egress field or a broad public-egress rule. +Live CNI handling of HBONE, waypoint routing, DNS, TLS and actual telemetry +export remain unqualified; successful new-environment export is a rollout +prerequisite. The AWS overlay replaces stateful services with managed dependencies. A pod-selector rule cannot authorize an RDS, ElastiCache, external Vault, or S3 From 4ec66a5a5fda3aa708257fb2cadf94241717a1b8 Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:01:18 +0000 Subject: [PATCH 20/20] fix(security): consume patched Next.js and Sentry releases --- module/services/frontend/code/next-env.d.ts | 4 +- .../services/frontend/code/package-lock.json | 407 ++++++++++++------ module/services/frontend/code/package.json | 6 +- .../services/marketing/code/package-lock.json | 82 ++-- module/services/marketing/code/package.json | 2 +- 5 files changed, 326 insertions(+), 175 deletions(-) diff --git a/module/services/frontend/code/next-env.d.ts b/module/services/frontend/code/next-env.d.ts index a419cbe4e..ce4e94a6b 100644 --- a/module/services/frontend/code/next-env.d.ts +++ b/module/services/frontend/code/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/dev/types/routes.d.ts"; -import "./.next/dev/types/root-params.d.ts"; +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index b8ba2435b..95b7b307c 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -30,7 +30,7 @@ "@fontsource/source-serif-4": "^5.3.0", "@hookform/resolvers": "^3.9.0", "@module-federation/runtime": "^2.8.2", - "@sentry/nextjs": "^10.50.0", + "@sentry/nextjs": "10.75.2", "@simplewebauthn/browser": "^13.3.0", "@tanstack/react-query": "^5.60.0", "@tanstack/react-table": "^8.21.3", @@ -38,7 +38,7 @@ "codefly": "https://github.com/codefly-dev/sdk-js/archive/9f3bb78682a0c2b9a70631d35c2ae0094c281ac5.tar.gz", "js-yaml": "^4.3.2", "lucide-react": "^1.8.0", - "next": "^16.2.12", + "next": "16.4.1", "react": "^19.2.8", "react-dom": "^19.2.8", "react-hook-form": "^7.54.0", @@ -64,7 +64,7 @@ "ajv": "^8.17.1", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9.39.5", - "eslint-config-next": "^16.2.12", + "eslint-config-next": "16.4.1", "happy-dom": "^20.8.9", "jsdom": "^29.0.2", "msw": "^2.7.0", @@ -2569,15 +2569,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", - "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.1.tgz", + "integrity": "sha512-5sUUrrSvs/2/2LnLoesybMGx12EtPB46fdZ6RhAw0XDGSPxU2LFyQBB7yuEiOFFJPKmBY6+q2jTr1/AMbnp0/g==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.4.tgz", - "integrity": "sha512-szW9y2Aumu4z88YXfTzcFsgUAg2k64uzbtcO5L9f1AKS4w/GUKJcbFllRflROVyNPgJtGOnvNxiyp3v6b+prIA==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.1.tgz", + "integrity": "sha512-2io6M+wAqe7nT5mt2gEVy6FOzye2DmwhjaYCpSvHfrIKL11SXNTwSARxpPHJacRmfgSBSDnNmnEXSp0DaJ23BQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2618,9 +2618,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", - "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.1.tgz", + "integrity": "sha512-U+hQ5Gj3fpzf4fveTIF3dpUFYhaSW8vHhdAnbiIomzXzg7boreYFgBqprepXFtW4sw+aTttiNmg0gew/iFNhbg==", "cpu": [ "arm64" ], @@ -2634,9 +2634,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", - "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.1.tgz", + "integrity": "sha512-/hqC3V1XLUjhMASf/WridKdO9FXoEgN3HBWmtKbj00MPgnltutfraWJ3bYTpCRIgEIpbjJyOVLpYgAhh/BkdcQ==", "cpu": [ "x64" ], @@ -2650,9 +2650,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", - "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.1.tgz", + "integrity": "sha512-GHzncHbcaS9g95kcvTNtKQW3aCLoR+xc3Jm7qrkpk1R+D2BjMHIPyvQGRPKCdMX95BsH/421jBUK06q9tEwzjw==", "cpu": [ "arm64" ], @@ -2669,9 +2669,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", - "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.1.tgz", + "integrity": "sha512-1SuJyDxW/PR9dHJat/ZRgcYLhuSZ+1psP4DlKb2tKIuXbjygqpLrJWRSq+onZQBv8LcmU67EIrmb6c3XTaJLjw==", "cpu": [ "arm64" ], @@ -2688,9 +2688,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", - "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.1.tgz", + "integrity": "sha512-Istng1hMGp9TGO4lUqeKLd70MPN/OOkucVTP2Nmj+gLkwBgmPqUw8CJYBCC7nbl/B/23bBCcjCI5Hsh6hazC1A==", "cpu": [ "x64" ], @@ -2707,9 +2707,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", - "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.1.tgz", + "integrity": "sha512-aSC9wMNiZRRFVpPkPWBzRwLQxIGm/CiQcXyFF1XNcgodb+uMxdk7aSSGGpDXCbnvZdf4itjHxpOd5h7i7zaiYw==", "cpu": [ "x64" ], @@ -2726,9 +2726,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", - "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.1.tgz", + "integrity": "sha512-t/myvwFDlypMUjRvbBf8qLPyuaB0iXYYdBtwMt6DM6f+60wtGr4bSYholdPqNeV5VUXZhrpMwfza829kmhS3iA==", "cpu": [ "arm64" ], @@ -2742,9 +2742,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", - "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.1.tgz", + "integrity": "sha512-6ICqPo9awCkrtYNPhwkx1uU7rlFWrZRrIboDHS2LU+jv088UBYQtmJ25h6pCp27/+FZjF6BQ4Te4ErugyMeuBg==", "cpu": [ "x64" ], @@ -3522,30 +3522,54 @@ } }, "node_modules/@sentry/browser": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.73.0.tgz", - "integrity": "sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==", + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.75.2.tgz", + "integrity": "sha512-2Q8Bd6aTIHmqWL/rGDZdr1JTGW2fYPXf+lMyhHp4CX6OE7gMaaI5ZUwQWgVelqUOvKkOZHHk0wo6K8+ExdwawQ==", "license": "MIT", "dependencies": { - "@sentry/browser-utils": "10.73.0", + "@sentry/browser-utils": "10.75.2", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.73.0", - "@sentry/feedback": "10.73.0", - "@sentry/replay": "10.73.0", - "@sentry/replay-canvas": "10.73.0" + "@sentry/core": "10.75.2", + "@sentry/feedback": "10.75.2", + "@sentry/replay": "10.75.2", + "@sentry/replay-canvas": "10.75.2" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/browser-utils": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.73.0.tgz", - "integrity": "sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==", + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.75.2.tgz", + "integrity": "sha512-U9RIJE0vZHWEnfEpJAa/SQ60MsNSUaxww4MVymNLwJ26BrLFz9jLhVIW9bXcx0i1iwK+KQbIv6ebPDOnCfYUPQ==", "license": "MIT", "dependencies": { "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.73.0" + "@sentry/core": "10.75.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser-utils/node_modules/@sentry/core": { + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.2.tgz", + "integrity": "sha512-KXzNf2c430cJIOMv84+MKpkXgave1DBNMNNwn073olVD773NME5IhDv/9VQyiFM2wIU+6bm4FEBVEVSuzqgjJw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser/node_modules/@sentry/core": { + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.2.tgz", + "integrity": "sha512-KXzNf2c430cJIOMv84+MKpkXgave1DBNMNNwn073olVD773NME5IhDv/9VQyiFM2wIU+6bm4FEBVEVSuzqgjJw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" }, "engines": { "node": ">=18" @@ -3798,34 +3822,46 @@ } }, "node_modules/@sentry/feedback": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.73.0.tgz", - "integrity": "sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==", + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.75.2.tgz", + "integrity": "sha512-/nrtMVSxOqBAigMWxIQS+Y8OqQ27ty+isMh6+X96kfAp/SaGrNZ425SZxKwq0mwHS0WnVkF+rE5dJnbbi56XSA==", "license": "MIT", "dependencies": { - "@sentry/core": "10.73.0" + "@sentry/core": "10.75.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/feedback/node_modules/@sentry/core": { + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.2.tgz", + "integrity": "sha512-KXzNf2c430cJIOMv84+MKpkXgave1DBNMNNwn073olVD773NME5IhDv/9VQyiFM2wIU+6bm4FEBVEVSuzqgjJw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/nextjs": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/nextjs/-/nextjs-10.73.0.tgz", - "integrity": "sha512-lZFlQkafIHnYKTo0QCY+VqFC7rEt/SRAVrTCKyM+r1yIAw+iIfU1qM2QT7xn71+t0U520I0xBJ2O+YzqLYXJIQ==", + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/nextjs/-/nextjs-10.75.2.tgz", + "integrity": "sha512-ilzb3E7Kaskx/RQ355kCkxLxrHBtYS67pe6KO+F/O6s53f5t4sVwz4gE2Mz4ScghDXeKM+ccyB5GMq9dlkQ35A==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", "@rollup/plugin-commonjs": "28.0.1", - "@sentry/browser-utils": "10.73.0", + "@sentry/browser-utils": "10.75.2", "@sentry/bundler-plugin-core": "^5.3.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.73.0", - "@sentry/node": "10.73.0", - "@sentry/opentelemetry": "10.73.0", - "@sentry/react": "10.73.0", - "@sentry/server-utils": "10.73.0", - "@sentry/vercel-edge": "10.73.0", + "@sentry/core": "10.75.2", + "@sentry/node": "10.75.2", + "@sentry/opentelemetry": "10.75.2", + "@sentry/react": "10.75.2", + "@sentry/server-utils": "10.75.2", + "@sentry/vercel-edge": "10.75.2", "@sentry/webpack-plugin": "^5.3.0", "rollup": "^4.60.3", "stacktrace-parser": "^0.1.11" @@ -3837,20 +3873,32 @@ "next": "^13.2.0 || ^14.0 || ^15.0.0-rc.0 || ^16.0.0-0" } }, + "node_modules/@sentry/nextjs/node_modules/@sentry/core": { + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.2.tgz", + "integrity": "sha512-KXzNf2c430cJIOMv84+MKpkXgave1DBNMNNwn073olVD773NME5IhDv/9VQyiFM2wIU+6bm4FEBVEVSuzqgjJw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sentry/node": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.73.0.tgz", - "integrity": "sha512-jiMJ6GgXDw6UMGzJY+o0c8OoeA9OfHqZ/xEpHfDqy75hn+9CEkRkbNGCIdMvsc7wW/Se1Os394hHfTpU+YEskg==", + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.75.2.tgz", + "integrity": "sha512-ffdx1i3Fx9Q3Ofdaone2I+HWOj9+Wcu268xgV+Bn8WJYABm8BZIBNvS+PORKBPesWILhQCThWOs+E/8vcV1HuQ==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.73.0", - "@sentry/node-core": "10.73.0", - "@sentry/opentelemetry": "10.73.0", - "@sentry/server-utils": "10.73.0", + "@sentry/core": "10.75.2", + "@sentry/node-core": "10.75.2", + "@sentry/opentelemetry": "10.75.2", + "@sentry/server-utils": "10.75.2", "import-in-the-middle": "^3.0.0" }, "engines": { @@ -3858,14 +3906,14 @@ } }, "node_modules/@sentry/node-core": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.73.0.tgz", - "integrity": "sha512-GHAGUmZPmm6FKfxfv2maVVJ/A99YAmd7oFOuKMDmITI6O/Msl6JB3vPTEpopVAHLW0LYJQBOjddL9C7o+Jt44g==", + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.75.2.tgz", + "integrity": "sha512-6sFhrvWVLAPRTOYkwrOOLwgsWINe6Y75oX9xKj11zZcKd2e1K2Y3bcx3uzDU06+cxfVbUQUBulsCr/TD7yx/vw==", "license": "MIT", "dependencies": { "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.73.0", - "@sentry/opentelemetry": "10.73.0", + "@sentry/core": "10.75.2", + "@sentry/opentelemetry": "10.75.2", "import-in-the-middle": "^3.0.0" }, "engines": { @@ -3896,14 +3944,38 @@ } } }, + "node_modules/@sentry/node-core/node_modules/@sentry/core": { + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.2.tgz", + "integrity": "sha512-KXzNf2c430cJIOMv84+MKpkXgave1DBNMNNwn073olVD773NME5IhDv/9VQyiFM2wIU+6bm4FEBVEVSuzqgjJw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node/node_modules/@sentry/core": { + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.2.tgz", + "integrity": "sha512-KXzNf2c430cJIOMv84+MKpkXgave1DBNMNNwn073olVD773NME5IhDv/9VQyiFM2wIU+6bm4FEBVEVSuzqgjJw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sentry/opentelemetry": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.73.0.tgz", - "integrity": "sha512-fQouPQKsH0CQrw6oAn1k0Z2I+tgyochCovifr5qNS69i0OzjknLa03WJyiZ/IuzXc4AVa5jAKfOeE9slABz8Qw==", + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.75.2.tgz", + "integrity": "sha512-qoJIdrXiFPPYs7AmdkktB5O4vhHYKzk6EulpQlvROe+ZZeG4/S0yeJApAbg3ThDob/2zEAxYEPEvaHv2epipgA==", "license": "MIT", "dependencies": { "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.73.0" + "@sentry/core": "10.75.2" }, "engines": { "node": ">=18" @@ -3914,15 +3986,27 @@ "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, + "node_modules/@sentry/opentelemetry/node_modules/@sentry/core": { + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.2.tgz", + "integrity": "sha512-KXzNf2c430cJIOMv84+MKpkXgave1DBNMNNwn073olVD773NME5IhDv/9VQyiFM2wIU+6bm4FEBVEVSuzqgjJw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sentry/react": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.73.0.tgz", - "integrity": "sha512-wJrzS98ddPvhGS/MKNHZyE8X7ecd4KwKdz7fHino2qkqBBrF4cxWr+q/uLTIk5PYWMkeJ4oKMjHAjOqmzGR4tg==", + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.75.2.tgz", + "integrity": "sha512-4MtB1tSmMHzwTwY7m30UzRbbUx9cP84UK7WIDL9g7RvoGd/aZwxbp6IkUJ46VBNItVoSF+1GM9wtqzkLma252g==", "license": "MIT", "dependencies": { - "@sentry/browser": "10.73.0", + "@sentry/browser": "10.75.2", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.73.0" + "@sentry/core": "10.75.2" }, "engines": { "node": ">=18" @@ -3931,53 +4015,113 @@ "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, + "node_modules/@sentry/react/node_modules/@sentry/core": { + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.2.tgz", + "integrity": "sha512-KXzNf2c430cJIOMv84+MKpkXgave1DBNMNNwn073olVD773NME5IhDv/9VQyiFM2wIU+6bm4FEBVEVSuzqgjJw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sentry/replay": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.73.0.tgz", - "integrity": "sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==", + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.75.2.tgz", + "integrity": "sha512-cCfdPt892S+rrAFOuUvbwR318/ZPFfBFvVkMlL3goktAnbECAGy6comAxLQEalyp22Ee67bEqYQN7RdNuRHIJw==", "license": "MIT", "dependencies": { - "@sentry/browser-utils": "10.73.0", - "@sentry/core": "10.73.0" + "@sentry/browser-utils": "10.75.2", + "@sentry/core": "10.75.2" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/replay-canvas": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.73.0.tgz", - "integrity": "sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==", + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.75.2.tgz", + "integrity": "sha512-4GVhCkn+/0QEpXdY8UfXahg8qjF2QTH1+qej/CLketHGfRqxLhdanzHU9ILesplWX5GeuTkPn5ZTmd5CJf+KXg==", "license": "MIT", "dependencies": { - "@sentry/core": "10.73.0", - "@sentry/replay": "10.73.0" + "@sentry/core": "10.75.2", + "@sentry/replay": "10.75.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay-canvas/node_modules/@sentry/core": { + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.2.tgz", + "integrity": "sha512-KXzNf2c430cJIOMv84+MKpkXgave1DBNMNNwn073olVD773NME5IhDv/9VQyiFM2wIU+6bm4FEBVEVSuzqgjJw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay/node_modules/@sentry/core": { + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.2.tgz", + "integrity": "sha512-KXzNf2c430cJIOMv84+MKpkXgave1DBNMNNwn073olVD773NME5IhDv/9VQyiFM2wIU+6bm4FEBVEVSuzqgjJw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/server-utils": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.73.0.tgz", - "integrity": "sha512-QskripdKFbM/+gipC6mpa2crLwL7+VbkX84IpHg2z9UlYQ1kNKd3aMT+Qk9NLRSw/zu1rSIAvlbWfx4D3rgNAA==", + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.75.2.tgz", + "integrity": "sha512-ep8Ntj/vsLHbql1jUMFiRYrleuQ3dENb8oD4Nkjv7hou2j0UKLct1eLbx+TckeVOhYdWmFeO7WJWjRio/a4TOg==", "license": "MIT", "dependencies": { "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.73.0" + "@sentry/core": "10.75.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/server-utils/node_modules/@sentry/core": { + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.2.tgz", + "integrity": "sha512-KXzNf2c430cJIOMv84+MKpkXgave1DBNMNNwn073olVD773NME5IhDv/9VQyiFM2wIU+6bm4FEBVEVSuzqgjJw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/vercel-edge": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/vercel-edge/-/vercel-edge-10.73.0.tgz", - "integrity": "sha512-DJLqVzd3ng4IwR+ux/vK1Wth/CSwUrsiwIbNsyneHw79cyhfjYuovFTw1xrjn9zTJoVSE8KkdajLSLwmLbnoTw==", + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/vercel-edge/-/vercel-edge-10.75.2.tgz", + "integrity": "sha512-Ol2kQfob60s4qAeitUt2XXG9of/jUCqhQ3loMx5nASJn9Y1C86XZOqzbVuzBDIoMQVBPV2JGCD8HgWHiN5HPbw==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/core": "10.73.0" + "@sentry/core": "10.75.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/vercel-edge/node_modules/@sentry/core": { + "version": "10.75.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.2.tgz", + "integrity": "sha512-KXzNf2c430cJIOMv84+MKpkXgave1DBNMNNwn073olVD773NME5IhDv/9VQyiFM2wIU+6bm4FEBVEVSuzqgjJw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" }, "engines": { "node": ">=18" @@ -7147,9 +7291,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", - "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.3.0.tgz", + "integrity": "sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==", "license": "MIT" }, "node_modules/class-variance-authority": { @@ -7620,9 +7764,9 @@ "peer": true }, "node_modules/dompurify": { - "version": "3.4.16", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", - "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -7840,7 +7984,8 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/es-object-atoms": { "version": "1.1.2", @@ -8029,21 +8174,21 @@ } }, "node_modules/eslint-config-next": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.4.tgz", - "integrity": "sha512-35/8RM10huEL9vlr8hUZMERMENHBrnyHN3ZZkF9efSgzGaqK34jIqry44A956//zriUhUAUW0XSkcolhrryqAA==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.4.1.tgz", + "integrity": "sha512-6AsojbCfRdpWkP6BIUnMBv3MH4OvtfmH9p4hSfLh8ZgsW7nfhu7DpUMNBB7leisDyyMz+YYxTc+NAdBOQfovlA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.3.4", + "@next/eslint-plugin-next": "16.4.1", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^7.0.0", + "eslint-plugin-react-hooks": "^7.1.0", "globals": "16.4.0", - "typescript-eslint": "^8.46.0" + "typescript-eslint": "^8.56.0" }, "peerDependencies": { "eslint": ">=9.0.0", @@ -9300,19 +9445,25 @@ } }, "node_modules/import-in-the-middle": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.4.0.tgz", - "integrity": "sha512-Xfjwfarhe+LGmoaof+sexeNo3sGRysb5x56WrZLwHtmqT0OilSKtVWkv0lrCcMgSKyKP9oBogBAisHZvtJH0aw==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.5.2.tgz", + "integrity": "sha512-WseIA/4o56+GYO3RmlOPvejYi771OFeJEeK/ejROsr9bdjW+E5NXTOD1Di8TL40Qgg3JJX9aa97Hf8iiSVIKZw==", "license": "Apache-2.0", "dependencies": { "cjs-module-lexer": "^2.2.0", - "es-module-lexer": "^2.2.0", + "es-module-lexer": "^3.0.2", "module-details-from-path": "^1.0.4" }, "engines": { "node": ">=18" } }, + "node_modules/import-in-the-middle/node_modules/es-module-lexer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.4.tgz", + "integrity": "sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA==", + "license": "MIT" + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -11742,12 +11893,12 @@ } }, "node_modules/next": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", - "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.1.tgz", + "integrity": "sha512-uc657V62E34fOI6W5NtG9IzwsPcms0Z60grz2MJGtglgD+pTFnNt4Yw2PzaU622tfX4vhrDgLZzicE2YlhI20Q==", "license": "MIT", "dependencies": { - "@next/env": "16.3.8", + "@next/env": "16.4.1", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -11761,15 +11912,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.8", - "@next/swc-darwin-x64": "16.3.8", - "@next/swc-linux-arm64-gnu": "16.3.8", - "@next/swc-linux-arm64-musl": "16.3.8", - "@next/swc-linux-x64-gnu": "16.3.8", - "@next/swc-linux-x64-musl": "16.3.8", - "@next/swc-win32-arm64-msvc": "16.3.8", - "@next/swc-win32-x64-msvc": "16.3.8", - "sharp": "^0.35.4" + "@next/swc-darwin-arm64": "16.4.1", + "@next/swc-darwin-x64": "16.4.1", + "@next/swc-linux-arm64-gnu": "16.4.1", + "@next/swc-linux-arm64-musl": "16.4.1", + "@next/swc-linux-x64-gnu": "16.4.1", + "@next/swc-linux-x64-musl": "16.4.1", + "@next/swc-win32-arm64-msvc": "16.4.1", + "@next/swc-win32-x64-msvc": "16.4.1", + "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json index f3277d9e8..15b6e0eb6 100644 --- a/module/services/frontend/code/package.json +++ b/module/services/frontend/code/package.json @@ -54,7 +54,7 @@ "@fontsource/source-serif-4": "^5.3.0", "@hookform/resolvers": "^3.9.0", "@module-federation/runtime": "^2.8.2", - "@sentry/nextjs": "^10.50.0", + "@sentry/nextjs": "10.75.2", "@simplewebauthn/browser": "^13.3.0", "@tanstack/react-query": "^5.60.0", "@tanstack/react-table": "^8.21.3", @@ -62,7 +62,7 @@ "codefly": "https://github.com/codefly-dev/sdk-js/archive/9f3bb78682a0c2b9a70631d35c2ae0094c281ac5.tar.gz", "js-yaml": "^4.3.2", "lucide-react": "^1.8.0", - "next": "^16.2.12", + "next": "16.4.1", "react": "^19.2.8", "react-dom": "^19.2.8", "react-hook-form": "^7.54.0", @@ -88,7 +88,7 @@ "ajv": "^8.17.1", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9.39.5", - "eslint-config-next": "^16.2.12", + "eslint-config-next": "16.4.1", "happy-dom": "^20.8.9", "jsdom": "^29.0.2", "msw": "^2.7.0", diff --git a/module/services/marketing/code/package-lock.json b/module/services/marketing/code/package-lock.json index 2bc7701a4..e6c4817b8 100644 --- a/module/services/marketing/code/package-lock.json +++ b/module/services/marketing/code/package-lock.json @@ -8,7 +8,7 @@ "name": "@codefly/marketing", "version": "0.0.1", "dependencies": { - "next": "^16.2.12", + "next": "16.4.1", "react": "^19.2.8", "react-dom": "^19.2.8", "yaml": "^2.8.1", @@ -1024,15 +1024,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", - "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.1.tgz", + "integrity": "sha512-5sUUrrSvs/2/2LnLoesybMGx12EtPB46fdZ6RhAw0XDGSPxU2LFyQBB7yuEiOFFJPKmBY6+q2jTr1/AMbnp0/g==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", - "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.1.tgz", + "integrity": "sha512-U+hQ5Gj3fpzf4fveTIF3dpUFYhaSW8vHhdAnbiIomzXzg7boreYFgBqprepXFtW4sw+aTttiNmg0gew/iFNhbg==", "cpu": [ "arm64" ], @@ -1046,9 +1046,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", - "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.1.tgz", + "integrity": "sha512-/hqC3V1XLUjhMASf/WridKdO9FXoEgN3HBWmtKbj00MPgnltutfraWJ3bYTpCRIgEIpbjJyOVLpYgAhh/BkdcQ==", "cpu": [ "x64" ], @@ -1062,9 +1062,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", - "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.1.tgz", + "integrity": "sha512-GHzncHbcaS9g95kcvTNtKQW3aCLoR+xc3Jm7qrkpk1R+D2BjMHIPyvQGRPKCdMX95BsH/421jBUK06q9tEwzjw==", "cpu": [ "arm64" ], @@ -1081,9 +1081,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", - "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.1.tgz", + "integrity": "sha512-1SuJyDxW/PR9dHJat/ZRgcYLhuSZ+1psP4DlKb2tKIuXbjygqpLrJWRSq+onZQBv8LcmU67EIrmb6c3XTaJLjw==", "cpu": [ "arm64" ], @@ -1100,9 +1100,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", - "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.1.tgz", + "integrity": "sha512-Istng1hMGp9TGO4lUqeKLd70MPN/OOkucVTP2Nmj+gLkwBgmPqUw8CJYBCC7nbl/B/23bBCcjCI5Hsh6hazC1A==", "cpu": [ "x64" ], @@ -1119,9 +1119,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", - "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.1.tgz", + "integrity": "sha512-aSC9wMNiZRRFVpPkPWBzRwLQxIGm/CiQcXyFF1XNcgodb+uMxdk7aSSGGpDXCbnvZdf4itjHxpOd5h7i7zaiYw==", "cpu": [ "x64" ], @@ -1138,9 +1138,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", - "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.1.tgz", + "integrity": "sha512-t/myvwFDlypMUjRvbBf8qLPyuaB0iXYYdBtwMt6DM6f+60wtGr4bSYholdPqNeV5VUXZhrpMwfza829kmhS3iA==", "cpu": [ "arm64" ], @@ -1154,9 +1154,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", - "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.1.tgz", + "integrity": "sha512-6ICqPo9awCkrtYNPhwkx1uU7rlFWrZRrIboDHS2LU+jv088UBYQtmJ25h6pCp27/+FZjF6BQ4Te4ErugyMeuBg==", "cpu": [ "x64" ], @@ -1339,12 +1339,12 @@ } }, "node_modules/next": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", - "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.1.tgz", + "integrity": "sha512-uc657V62E34fOI6W5NtG9IzwsPcms0Z60grz2MJGtglgD+pTFnNt4Yw2PzaU622tfX4vhrDgLZzicE2YlhI20Q==", "license": "MIT", "dependencies": { - "@next/env": "16.3.8", + "@next/env": "16.4.1", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -1358,15 +1358,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.8", - "@next/swc-darwin-x64": "16.3.8", - "@next/swc-linux-arm64-gnu": "16.3.8", - "@next/swc-linux-arm64-musl": "16.3.8", - "@next/swc-linux-x64-gnu": "16.3.8", - "@next/swc-linux-x64-musl": "16.3.8", - "@next/swc-win32-arm64-msvc": "16.3.8", - "@next/swc-win32-x64-msvc": "16.3.8", - "sharp": "^0.35.4" + "@next/swc-darwin-arm64": "16.4.1", + "@next/swc-darwin-x64": "16.4.1", + "@next/swc-linux-arm64-gnu": "16.4.1", + "@next/swc-linux-arm64-musl": "16.4.1", + "@next/swc-linux-x64-gnu": "16.4.1", + "@next/swc-linux-x64-musl": "16.4.1", + "@next/swc-win32-arm64-msvc": "16.4.1", + "@next/swc-win32-x64-msvc": "16.4.1", + "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", diff --git a/module/services/marketing/code/package.json b/module/services/marketing/code/package.json index c15dbbc38..523d07aa4 100644 --- a/module/services/marketing/code/package.json +++ b/module/services/marketing/code/package.json @@ -18,7 +18,7 @@ "test:smoke": "node scripts/smoke.mjs" }, "dependencies": { - "next": "^16.2.12", + "next": "16.4.1", "react": "^19.2.8", "react-dom": "^19.2.8", "yaml": "^2.8.1",