feat(tracing): keyless trace export to Google Cloud (TRACE_EXPORTER=gcp) - #4207
Umang01-hash merged 10 commits into
Conversation
3ac2f16 to
46ecc48
Compare
4b6a327 to
238fea7
Compare
aryanmehrotra
left a comment
There was a problem hiding this comment.
Reviewed at 238fea7be. The module boundary is exactly right and I measured it rather than trusting it. Two small factual corrections and one inherited defect.
Verified
| Claim | Result |
|---|---|
gofr.dev gains no Google dependencies |
✅ main go.mod/go.sum untouched by the whole stack |
| Users who do not import it pay nothing | ✅ binary byte-identical to #4206 — 61,118,450 bytes at both commits, +0 |
| Dependabot extended | ✅ /pkg/gofr/traces/exporters/* added; every module in the tree matches a glob |
gofmt / build / vet / module tests |
✅ ok gofr.dev/pkg/gofr/traces/exporters/gcp |
For scale: the whole three-PR stack costs a non-GCP user +16.5 KB on a 61 MB binary (+0.027%), and this PR — the largest of the three — costs zero. The submodule-plus-blank-import pattern does what it promises. The new module is also lighter than the precedent it copies: 7 direct / 21 indirect / 71 go.sum lines against metrics/exporters/gcp's 8 / 27 / 91.
The no-credentials path is genuinely well built. With ADC unavailable and the metadata server unreachable:
WARN gcp traces: no gcp.project_id could be resolved from the ambient credentials and this host
is not on Google Cloud; spans may not reach a project. Set GOOGLE_CLOUD_PROJECT=<project-id>
ERROR failed to initialize "gcp" trace exporter: gcp traces: resolving application default
credentials: ... open /nonexistent/creds.json: no such file or directory; tracing is disabled
BOOT SURVIVED: recording=false traceIDvalid=true spanIDvalid=true shutdownFunc=true
The app starts, the message names the remediation, and correlation IDs stay valid. That is the behaviour I would want and it is not the default one gets for free.
1. The dependabot comment count is wrong
comment: "The tree has 37 go.mod directories"
measured: df192fc08 = 32 -> this PR adds 2 -> 34
I counted at all three stack commits: 32 / 32 / 34. Off by three. Normally a nit, except that comment's entire job is "the globs below must cover all of them" — an inflated count tells the next reader three modules are unaccounted for and sends them looking for something that is not there. (I checked: every module does match a glob.)
2. "It is tracked for removal" is not true yet
The replace gofr.dev => ../../../../.. carries a // TODO: and the description says it "is tracked for removal". I searched open and closed issues — there is none.
This matters more than a normal TODO because the constraint is load-bearing: a replace in a dependency module is ignored by consumers, so go get gofr.dev/pkg/gofr/traces/exporters/gcp resolves gofr.dev v1.60.1 from the proxy, which does not contain pkg/gofr/traces/exporters. Nobody can use this module until a tagged release ships #4206. That is a release-ordering constraint someone has to remember at tag time, and a // TODO: in a go.mod is the weakest possible place for it — the same argument that moved #3940's deferred gate onto #4155. Could you file it?
3. buildExporter panics on a nil Logger
buildExporter(ctx, cfg, nil) -> panic: nil pointer dereference
Same root cause I have described on #4206: noopLogger exists and is documented as the fallback for callers that supply no logger, but nothing substitutes it. Inherited from metrics/exporters rather than introduced here — I confirmed that package panics identically — so it is a three-package fix, not a defect of this PR. Noting it here only so the GCP module is on the list.
Not verifiable from here
Your own caveat that this has not been exercised against a live GCP project is the right thing to have written down, and it is the part I cannot close: the roles/telemetry.tracesWriter over roles/cloudtrace.agent claim and the gcp.project_id routing question both rest on Google's docs. That is a maintainer call on whether to merge ahead of a real Cloud Run deploy of examples/using-gcp-traces, not a review finding.
Items 1 and 2 are small and worth fixing before merge; item 3 is shared with #4206.
238fea7 to
cd33afc
Compare
|
Thanks — measuring the binary at both commits rather than taking the claim is the check I would have wanted, and "+0 bytes, byte-identical" is a better statement of the module boundary than anything in my description. Both of your items are fixed in 1. The dependabot count. You are right, and I re-derived it independently rather than just taking the number: 2. "It is tracked for removal." Fair hit — that was not true when I wrote it. Filed as #4210, which spells out the ordering (#4206 merges → a tagged 3. Nil On the live-GCP caveat — agreed that is a maintainer call, and I would rather it be made with the caveat visible than have me quietly drop it. If you would prefer the deploy evidence first, I am happy to run |
cd33afc to
ecfe5f3
Compare
aryanmehrotra
left a comment
There was a problem hiding this comment.
Reviewed by planning #4204 independently first, freezing that plan, then reading the diff and comparing the two blind. Base origin/development @ 4f3e02dfc, branch @ e9ce0f807. Only this PR's own commits (60e15bdf1, ecfe5f371) — 355bfc4f4/0fd9cb5b7 are #4206's.
The module is a faithful port and the wiring is complete. Same ADC lookup, same WithTLSCredentials(credentials.NewTLS(nil)) + WithPerRPCCredentials(oauth.TokenSource{...}), same init() double registration, same caching detector returning a partial resource plus its error. go.work gets both entries, the dependabot glob was genuinely required (nothing matched pkg/gofr/traces/*), and the count correction to 34 is right — I measured 32 go.mod files at base, +2 here. go test -race -cover → ok, 93.9%; the example builds.
Two things to fix before merge, one comment to correct, and one thing I got wrong that you should ignore.
1 — a scheme-bearing TRACER_URL drops every span, and this PR's own docs promise it works
gcp.go:173-179 passes cfg.Endpoint straight to otlptracegrpc.WithEndpoint. Measured at the pinned otlptracegrpc v1.44.0:
endpoint "telemetry.googleapis.com:443"
-> dial attempted (expected)
endpoint "https://telemetry.googleapis.com:443"
-> rpc error: code = Unavailable
desc = invalid target address https://telemetry.googleapis.com:443,
error info: address https://telemetry.googleapis.com:443:443: too many colons in address
It fails at export, not at startup — so the app boots healthy, logs exporting traces to Google Cloud at …, and silently exports nothing. That is the exact failure class #4204 was filed about.
And this is the documented usage, not a misuse. This PR's own edit to the TRACER_URL row in docs/references/configs/page.md reads:
Required if TRACE_EXPORTER is set to zipkin, jaeger or otlp; optional for gcp, which defaults to
telemetry.googleapis.com:443. Anhttp://orhttps://scheme selects the transport; a schemelesshost:portis governed by TRACER_INSECURE.
One sentence tells the operator that TRACER_URL applies to gcp and that a scheme selects the transport. For gcp it does not. The most natural path to this is switching TRACE_EXPORTER from otlp to gcp and leaving TRACER_URL in place — and the module's own doc comment invites a custom endpoint for regional residency (gcp.go:17-19).
The fix already exists 40 lines away in the same package: traces/exporters/otlp.go:44-85, whose comment spells out this exact failure, and #4205 merged it for the otlp builder four days before this branch.
Cheapest fix: reuse resolveOtlpTransport, or reject/normalise a scheme in buildExporter and return an error so it fails at startup instead of silently at export.
Note this is inherited, not introduced — metrics/exporters/gcp/gcp.go:149-155 has the identical pattern, so it is one fix across two modules.
2 — the IAM role is asserted as fact, contradicts #4204, and omits the role its metrics twin requires
gcp.go:9-15 states roles/telemetry.tracesWriter (or roles/telemetry.writer) and that roles/cloudtrace.agent is "NOT sufficient". Three sources, three answers:
| source | says |
|---|---|
| issue #4204 (yours) | telemetry.traces.write is "the sole permission in roles/telemetry.tracesWriter, and included in roles/cloudtrace.agent" |
| this PR | roles/cloudtrace.agent is "NOT sufficient" |
this repo, same telemetry.googleapis.com endpoint (metrics/exporters/gcp/gcp.go:9-13) |
roles/telemetry.writer plus roles/serviceusage.serviceUsageConsumer on the quota project |
To be clear: I could not verify this and I am not claiming the PR is wrong. Google's IAM role pages are JS-rendered and returned no permission list; gcloud iam roles describe needs an interactive reauth I did not have. What I am reporting is that a control is stated as fact while this PR's own issue says the opposite, and the repo's nearest precedent names a different role plus a second one.
The serviceUsageConsumer omission is the part that worries me more than the cloudtrace.agent line. The module already knows quota projects matter — gcp.go:186-190 warns about x-goog-user-project and points at GOOGLE_CLOUD_QUOTA_PROJECT. If the quota-project grant is needed here as it demonstrably is for metrics, an operator following these docs gets a PERMISSION_DENIED that reads nothing like a missing role, while three separate places (package doc comment, README, deploy recipe) tell them the grant was complete. Role strings get copied verbatim into production IAM bindings.
Cheapest fix: one command settles all three —
gcloud iam roles describe roles/cloudtrace.agent --format='value(includedPermissions)'
gcloud iam roles describe roles/telemetry.tracesWriter --format='value(includedPermissions)'
Until then, mark it documented-not-verified (the PR body is already admirably honest about not having run this against a live project — the doc comment should match that tone), and say explicitly whether serviceUsageConsumer applies.
3 — the replace comment's history is wrong (keep the directive, fix the two sentences)
gcp/go.mod:6-8:
The metrics exporter module carries no replace for exactly this reason — it was added only after the core it needs had shipped.
It shipped with one:
$ git show 5e00b8c6d:pkg/gofr/metrics/exporters/gcp/go.mod
8: gofr.dev v1.57.0
44: replace gofr.dev => ../../../../../
It was dropped 16 days later in ceb503900 (#3926) by pinning v1.59.0 — i.e. only once a release contained the parent package, which is precisely the four-step ordering #4210 describes. So the conclusion is right and only the stated history is wrong; the correct history actually supports what you did more strongly than the version in the comment.
⚠️ A finding I withdrew — please ignore any earlier impression that the replace is removable
I initially concluded the replace gofr.dev directive was dead code, having measured that go list -m and go test both ignore it because go.work wins. That was wrong, and you should not act on it.
CI runs cd "$dir" && go mod tidy -diff for every find pkg -name go.mod (.github/workflows/go.yml:604-616), and go mod tidy is workspace-blind:
with the replace, as you shipped it:
go mod tidy -diff -> exit=0
with the line deleted:
go mod tidy -diff -> exit=1
go: gofr.dev/pkg/gofr/traces/exporters/gcp imports
gofr.dev/pkg/gofr/traces/exporters: module gofr.dev@latest found (v1.61.0),
but does not contain package gofr.dev/pkg/gofr/traces/exporters
Removing it would turn the tidiness job red on the first push while the submodule test job stayed green — which is exactly why my first measurement missed it. The directive is load-bearing and #4210 is correctly scoped. Recording the correction here rather than quietly dropping it, because the transferable lesson is that go test and go mod tidy have different blindnesses and only the latter covers a submodule's own replace.
Notes, not blocking
- The ADC-failure path is structurally untestable as written.
buildExporteruses the package globaldefaultADC(gcp.go:168) rather than the overridable field the detector has (cachingDetector.adc), so the one failure path that affects application start — ADC unresolvable, must degrade not abort — cannot be driven from a test. The metrics precedent never tests it either: every case in itsgcp_test.gocallswriteADC(t)first, which makesFindDefaultCredentialssucceed. If you want it covered, injectingadcinto the builder the way the detector already allows is the small change; unsettingGOOGLE_APPLICATION_CREDENTIALSalone is not enough on a GCE runner,GCE_METADATA_HOSThas to go too. - Rebase needed. The branch is 4 commits behind
developmentand conflicts inpkg/gofr/otel.goandpkg/gofr/otel_test.go. It is missing #4247 (redact tracer config in logs), a security fix — so the redaction behaviour should be re-verified after the rebase rather than assumed from this review.
Things I checked and am deliberately not flagging
Including two where your approach beat my independent plan — expand
- Honouring
cfg.Endpointwith adefaultEndpointfallback. My independent plan proposed a fixed endpoint that ignoresConfig.Endpointentirely. A blind comparison of the two plans called yours the simpler shape and it was right: mine would have created a second endpoint policy for the same destination and contradicteddocs/references/configs/page.md's documentedMETRICS_URLbehaviour for the metrics twin. Your shape is correct — finding 1 is about validating the value, not about rejecting the knob. - Correctly not porting the metrics-only constraints. The
prometheus_targetlocation/instancelabel lists and the cumulative-temporality guard are Managed Prometheus concerns; Cloud Trace has noprometheus_target. Porting them would have produced warnings an operator cannot act on. You kept the reusable half —Config.Resourcepublished before builders run, consumed bywarnMissingProject— which is exactly the right half. TRACER_INSECUREignored undergcp. My plan wanted a warning; I withdrew it. The config reference already scopes the key ("Supported for otlp, jaeger"), the repo's convention is a doc comment (metrics/exporters/config.go:38-40), and nothing is downgraded since TLS is forced. A confused operator at worst, not a security regression.gcp.project_idresolution — ADC →GOOGLE_CLOUD_PROJECT→ warn, with all three branches tested. Theauthorized_user-carries-no-project rationale in the comment is accurate.- Token expiry —
grpc.WithPerRPCCredentialsreturns an opaqueDialOptionwith no public accessor, so correct-by-construction is the honest bar here; not unit-assertable. - Zero Google dependencies in
gofr.dev'sgo.mod— confirmed unchanged. - The stated limitations — "not yet exercised against a live GCP project" and the
go getblocker deferred to #4210 are both stated plainly in the PR body. That is the right disposition and it is why finding 2 is about the doc comment's tone, not the PR's. - CI needs no edit — submodules are discovered by
find pkg -name "go.mod"(go.yml:433-437), so the new module is tested for free once thego.workentry exists.
Evidence: go test -race -cover ./... in the module ok 93.9% · go build ./... in examples/using-gcp-traces ok · go mod tidy -diff exit 0 as shipped · git merge-tree vs development → conflicts in otel.go, otel_test.go.
Merge #4206 first, then rebase this.
13cdfa2 to
dee9d67
Compare
aryanmehrotra
left a comment
There was a problem hiding this comment.
Re-reviewed at dee9d6728, now that #4206 is on development. I ran the gates locally rather than reading the badge, and checked out the branch to do it.
The mechanical half is fully closed, and the rebase is better than I asked for.
git merge-baseis0711243b5— the tip ofdevelopmentitself, 0 commits behind, conflicts 2 → 0, three commits, no merge commits. That also means #4247 landed under this, so I re-verified rather than assumed:redactExporterNameis live on the path this module registers through (provider.go:122).- All 28
pkg/submodules pass CI's own tidiness loop, exit 0.dee9d6728fixed the one that was red, and the diagnosis was right — the merge fromdevelopmentbumpedx/oauth2,x/sysandx/textin the root, and thereplacemeans this module inherits them, so itsgo.modwent stale without anything in the module changing. Worth recording as a standing cost: every root dependency bump lands here until #4210 closes. - The dependabot correction checks out.
git ls-files '*go.mod' | wc -lis 34 (28pkg/+ 5examples/+ root) against 32 at base, and I confirmed all 34 directories are matched by a glob —/pkg/gofr/traces/exporters/*closes the new one. Citing the command in the comment so the number stays checkable is the right instinct. go vetclean, module tests-raceok 93.9%,./pkg/gofr/traces/...ok,./pkg/gofr/ok,examples/using-gcp-tracesbuilds and vets clean, CI 16 pass / 1 skip / 0 fail.
Two new things this pass, and a correction I owe you. gcp.go is byte-identical to the version I reviewed, so findings 1–3 still stand as written — my earlier review shows as stale against e9ce0f807 but isn't.
New 1 — gcp.go:200 logs TRACER_URL unredacted
This one I missed the first time and it is the one I would fix first.
RedactURL's own doc comment (redact.go:17-19) is addressed at precisely this situation:
Every log line in this package that names an endpoint goes through it, and a Builder registered from outside should do the same: TRACER_URL is operator input and routinely carries a credential.
otlp.go:130, otlp.go:77 and zipkin.go:37 all comply. gcp.go:200 is the one endpoint log in the tree that does not:
logger.Infof("exporting traces to Google Cloud at %s via keyless ADC", endpoint)Measured against the real function, same three inputs through both paths:
TRACER_URL "https://svc:s3cr3t@telemetry.googleapis.com:443"
otlp/zipkin -> ... at https://REDACTED@telemetry.googleapis.com:443
gcp.go:200 -> ... at https://svc:s3cr3t@telemetry.googleapis.com:443
TRACER_URL "https://telemetry.googleapis.com:443?api-key=AIzaLIVEKEY"
otlp/zipkin -> ... at https://telemetry.googleapis.com:443?REDACTED
gcp.go:200 -> ... at https://telemetry.googleapis.com:443?api-key=AIzaLIVEKEY
TRACER_URL "telemetry.googleapis.com:443\nlevel=INFO msg=\"forged line\""
otlp/zipkin -> ... at telemetry.googleapis.com:443\x0alevel=INFO msg="forged line"
gcp.go:200 -> ... at telemetry.googleapis.com:443
level=INFO msg="forged line"
The third is the one the doc comment specifically calls out — "a raw newline would otherwise forge a second log line in the terminal's pretty output" — and it does.
I want to be accurate about the severity: TRACER_URL is operator config, not remote input, so this is not a remote-attacker path. But #4247 judged exactly this class worth a security fix four days ago, and the rule is written down in the function the module already links against. A new module landing on the other side of it is how a just-closed hole reopens.
Fix: RedactURL(endpoint). One call, already exported, already imported.
New 2 — examples/using-gcp-traces/go.mod is untidy, and CI cannot see it
go mod tidy -diff exits 1 with a 72-line diff. The module carries indirects older than the root it replaces:
| dependency | this example | root go.mod |
examples/using-gcp-metrics |
|---|---|---|---|
cloud.google.com/go/auth |
v0.22.0 | v0.23.2 | v0.23.2 |
googleapis/gax-go/v2 |
v2.23.0 | v2.24.0 | v2.24.0 |
enterprise-certificate-proxy |
v0.3.19 | v0.3.20 | v0.3.20 |
Same root cause as the pkg/ module dee9d6728 fixed — it is downstream of the root through a replace, so the merge moved it. It was missed because CI's gate is find pkg -name go.mod, and this one is under examples/. It is the only untidy module of the five examples.
Two things follow, and the second is arguably the more useful:
go buildhides it, becausego.workoverrides the stale requirements. An example is the one artifact people copy out of the workspace, and outside it the firstgo mod tidyrewrites 72 lines.- The exclusion's stated reason is no longer true.
go.yml:601-603says examples are skipped becauseexamples/using-gcp-metricsis currently untidy — I measured it tidy today, along with the other three. So examples are ungated by accident rather than by decision, and this PR's own example is now the only thing that exclusion is hiding. Worth a follow-up to re-scope the gate tofind pkg examples -name go.mod; not this PR's job, but this PR is what surfaced it.
Correction — my suggested fix for finding 1 was wrong
I wrote "reuse resolveOtlpTransport". That is not available to you. It is unexported, and gcp is a separate module — the exported surface of traces/exporters is exactly Build, Register, RegisterResourceDetector, RedactURL, RedactMessage.
It would also be the wrong shape. This destination is always TLS on 443, so there is no transport to select; the scheme is a value to reject, not interpret. The cheapest fix is local to buildExporter: return an error when cfg.Endpoint carries a scheme, so it fails at startup. Exporting a helper from the parent is a reasonable alternative, but it is a bigger change than I implied and I should not have described it as the cheap one.
Findings 1–3, still open
1 — a scheme-bearing TRACER_URL drops every span. Unchanged at gcp.go:173-179. Two things sharpen it since I filed it.
It is now provably outside the test suite's reach. I replaced otlptracegrpc.WithEndpoint(endpoint) with a hardcoded invalid target, discarding cfg.Endpoint entirely:
go test -run Test_buildExporter ./... -> ok
go test ./... -> ok (the full 93.9% suite)
The mutant survives. Test_buildExporter_endpoint asserts only err == nil && exp != nil, and both of its endpoint cases are schemeless — so the table covers the lines without constraining them. This is the same shape as the Test_buildResource blind spot in #4206, where the case picked was the one key that could not collide. A case is not enough here; the assertion has to be on the resolved endpoint.
And the first symptom is worse than I said. Re-measured at the pinned otlptracegrpc v1.44.0:
"telemetry.googleapis.com:443"
-> PermissionDenied: unregistered callers (socket opened, reached Google — the good path)
"https://telemetry.googleapis.com:443"
-> rpc error: code = Unavailable
desc = invalid target address https://telemetry.googleapis.com:443,
error info: address https://telemetry.googleapis.com:443:443: too many colons in address
That second error only surfaces after the exporter's ~30s export timeout. Under a shorter context the operator gets context deadline exceeded and nothing else — a message that points at the network rather than at the config.
The docs contradiction is also unchanged: this PR's own TRACER_URL row still reads "optional for gcp … An http:// or https:// scheme selects the transport", and production-tracing/page.md repeats "ignored when the URL carries a scheme" in the same table that lists gcp.
2 — the IAM role is asserted as fact in four places, not three. gcp.go:11,14, production-tracing/page.md:136-137, quick-start/observability/page.md:450-451, and examples/using-gcp-traces/README.md:37,40,42,58 — the README puts it inside a copy-pasteable gcloud ... --role="roles/telemetry.tracesWriter". Meanwhile roles/serviceusage.serviceUsageConsumer, which the metrics twin's README names at :48 for the same telemetry.googleapis.com endpoint, appears nowhere in this module — even though gcp.go:186-190 already warns about x-goog-user-project and GOOGLE_CLOUD_QUOTA_PROJECT, so the module plainly knows quota projects matter.
I still could not verify this and I am not claiming the PR is wrong. gcloud iam roles describe fails here with Reauthentication failed. cannot prompt during non-interactive execution. What I am reporting is that a control is stated as fact in four places, one of them executable, while #4204 says the opposite. Either run the two describe commands, or soften all four to documented-not-verified and say explicitly whether serviceUsageConsumer applies. Happy to run them the moment I have auth.
3 — the replace comment's history. Keep the directive — I withdrew that finding, and this round's tidy failure is a second demonstration that it is load-bearing. Only the two sentences of history are wrong: the metrics module did ship one (5e00b8c6d line 44, pinning v1.57.0), dropped in ceb503900 once a release contained the parent. That history supports what you did more strongly than the version currently in the comment.
Checked and deliberately not flagging
Including where the coverage gaps turned out to be exactly the right ones — expand
- The 6.1% uncovered maps precisely onto the two things already known.
withProjectID85.7% andbuildExporter88.2%, nothing else below 100%. No hidden gap. withProjectID's error branch is unreachable, and that is fine. At the pinnedotel/sdk v1.46.0,resource.Mergecannot return an error whenbis schemaless —case b.schemaURL == "": return NewWithAttributes(a.schemaURL, combine...), nil. Defensive, not dead weight. One latent nit if it ever becomes reachable: on that branchMergestill returns the fullcombineset, and the code discards it in favour ofattrsalone, which would drop every platform attribute. A one-word change if you are touching the function anyway.- The example's own config is right.
configs/.envandREADME.md:21,105all use schemeless forms and explain whyTRACER_URLis left unset. Finding 1 is about the config reference page, not about the example. - Dependabot glob coverage is complete, not just incremented — I matched all 34 module directories against the eight globs.
main.gois the right size for an example: blank import, one custom span, nothing else.resolveOtlpTransportbeing merged ondevelopmentmakes finding 1 a divergence from shipped in-repo behaviour rather than from a sibling branch. That is a reason to fix it, not a reason to reuse it — see the correction above.
Evidence: worktree checked out at dee9d6728, every command above run locally, worktree restored afterwards. 28 pkg/ modules tidy · gcp module -race -cover 93.9% · ./pkg/gofr/traces/... and ./pkg/gofr/ ok · example builds and vets clean · endpoint mutation survives the full suite · examples/using-gcp-traces go mod tidy -diff exit 1, 72 lines.
New 1 is a one-line fix and New 2 is a go mod tidy. Findings 2 and 3 are documentation. Finding 1 is the only one with design in it.
|
Pushed as New 1 — unredacted endpoint: fixed, and there was a second one
never reaches the startup log at all. So the forged-log-line case you measured survives a fix to line 200 alone: it just takes the error path instead. That is exactly the situation Two tests: Finding 1 — scheme rejected at startupTaking your correction: I considered normalising instead — strip Scheme detection is on On the vacuous test. You were right that a case wasn't enough, and the obvious repair isn't either: asserting on the startup log doesn't help, because the log reads the same variable the option does, so your mutant survives it too. The one observable that constrains it is a socket. Re-ran your mutation and two others:
Coverage 93.9% → 97.3%. Docs: the Finding 2 — I have auth, and the claim was wrong, not just unverifiedYou were careful to say you weren't claiming the PR was wrong. It was.
All four sites now say what's measured: On Finding 3 — directive kept, history correctedConfirmed both halves of your account: The comment now tells that history instead of claiming the metrics module never needed one. New 2 — example tidied
Your second point is the more useful one and I've filed it rather than folded it in: the exclusion at Also
#4210Not closeable yet, and I checked rather than assumed: Gates at |
There was a problem hiding this comment.
Re-reviewed at 9c796a54b. I built the example as a real binary and ran it against a socket rather than reasoning from the diff, because the thing under test is whether a connection arrives — and that is not something a unit test asserted before this push.
Four of the five items are closed properly, and two of them are better than what I asked for. The second leak you found on the error path is one I measured and missed: I checked the log line and stopped there, and your framing — that the rule belongs to the package, not the call site — is the correct reading of that doc comment. RedactMessage handles it because it replaces the strconv.Quote form as well as the raw one, which is exactly the case the SDK's parse "…" error produces.
The fifth is not closed, and it is in the code this push added. One leading space defeats the new guard entirely.
Edited after posting. I first filed the metadata-server hang below as a note. That was wrong and I have upgraded it to blocking. My reasoning was "the unbounded context is inherited" — but I used that same word about finding 1 in my first review and blocked on it anyway, so the classification was inconsistent with my own standard. What decides it is whether this PR makes a new failure mode reachable, not whose line the root cause sits on. Nothing else in this review changed.
Blocking — resolveEndpoint never trims, so the guard is one space wide
schemeOf looks for :// and then validates the prefix against RFC 3986 §3.1. A leading space fails isSchemeRune at position 0, so the function returns "" and the value is accepted as schemeless — and lands in WithEndpoint unchanged.
I ran the built example against a listener that counts connections. Fake ADC, TRACE_EXPORTER=gcp, TRACER_RATIO=1, one GET /hello, 6s for the batch to flush.
TRACER_URL |
startup log | dials |
|---|---|---|
127.0.0.1:34317 |
INFO exporting traces … at 127.0.0.1:34317 |
4 |
https://127.0.0.1:34318 |
ERROR … must be a schemeless host:port … tracing is disabled |
0 |
␣https://127.0.0.1:34319 |
INFO exporting traces … |
0 |
127.0.0.1:34320␣ |
INFO exporting traces … |
0 |
Row 3, verbatim:
11:26:15 INFO exporting traces to Google Cloud at https://127.0.0.1:34319 via keyless ADC
11:26:15 INFO {"trace_id":"e9e59af2…","uri":"/hello","response":200}
… 15 seconds …
11:26:30 ERROR invalid target address https://127.0.0.1:34319,
address https://127.0.0.1:34319:443: too many colons in address
TOTAL_DIALS=0
That is the original failure, unchanged, straight through the new guard: boots healthy, logs that it is exporting, exports nothing, and the only diagnostic arrives after the export timeout.
Row 4 is the one I would lead with. No scheme is involved at all — a correctly spelled host:port with one trailing space gives dial tcp: lookup tcp/34320 : unknown port, and zero dials. So this is not really a hole in the scheme check; it is that the endpoint is never normalised, and the scheme check is only the most visible thing that falls through it.
Nothing upstream trims. config.Get is a bare os.Getenv (pkg/gofr/config/godotenv.go:93-95). godotenv v1.5.1 trims unquoted .env values (parser.go:173) but for a quoted value trims only the quote character, so TRACER_URL=" https://…" keeps the space. And the plain environment-variable path — Cloud Run, GKE, docker -e, a K8s manifest with a stray space after the colon — has no trimming anywhere. That path is this exporter's whole reason to exist.
The same file already does this for the other operator-supplied value: gcp.go:145, strings.TrimSpace(os.Getenv(projectEnv)).
Fix, and I verified it is sufficient rather than assuming:
func resolveEndpoint(cfg *exporters.Config) (string, error) {
endpoint := strings.TrimSpace(cfg.Endpoint)
if endpoint == "" {
return defaultEndpoint, nil
}
...
}Rebuilt the example with that one line and re-ran the same two cases:
TRACER_URL |
with TrimSpace |
dials |
|---|---|---|
␣https://127.0.0.1:34321 |
ERROR … must be a schemeless host:port … tracing is disabled |
0 |
127.0.0.1:34322␣ |
INFO exporting traces … at 127.0.0.1:34322 (trimmed) |
4 |
Worth a case in Test_resolveEndpoint for each: a padded scheme-bearing value must reject, a padded schemeless one must resolve to the trimmed form.
Blocking — this PR makes an unbounded startup context reachable, and a wedged metadata server stops the app from serving at all
I went looking for the performance cost of the exporter and found this instead.
Normal startup is free: TRACE_EXPORTER=gcp boots in 33–107 ms off-GCP, indistinguishable from tracing off, because the metadata probe is refused instantly. But if something accepts the connection and never replies, boot blocks for exactly as long as it hangs:
GCE_METADATA_HOST |
boot to first 200 |
|---|---|
| absent (connection refused) | 33–107 ms |
| wedged, 12s — four runs | 11652 / 11585 / 11567 / 11625 ms |
| wedged, 20s | 19614 ms |
| wedged, tracing off (control) | 99 / 34 ms |
The control is what isolates it. The HTTP server never binds; there is no deadline anywhere on the path:
otel.go:74 exporters.Build(context.Background(), ...) <- no deadline
provider.go:56 buildResource(ctx, ...)
provider.go:199 resource.New(ctx, opts...)
provider.go:192 resource.WithDetectors(d)
gcp.go:71 cachingDetector
gcpdetect...Detect(ctx) -> HTTP to GCE_METADATA_HOST, unbounded
The context.Background() is not yours — it is otel.go:74, merged in #4206. But until this PR there was no trace resource detector for lookupDetector to find, so nothing on that path ever opened a socket. Registering the first one is what turns a latent unbounded context into a startup hang.
Why I am blocking on this rather than filing it. In the environment this exporter is built for, a container that never binds its port never passes its startup probe — so the failure presents as a Cloud Run deploy that will not go green, with nothing in it that points at tracing. That is a worse failure than the dropped spans I blocked on above, and this PR is what makes it reachable.
The metrics side already ships it — container/container.go:141 is also context.Background() and metrics/exporters/gcp/gcp.go:65 registers its own cachingDetector, so METRICS_EXPORTER=gcp has this today on development. That parent fix belongs in its own PR off development, and I am not asking for it here. What I am asking is that this PR not be the thing that ships the hang to the traces path: a deadline on the context the detector is given, or on the detector itself, is local to this module and enough.
Reproduction: a TCP listener that accepts and never replies, GCE_METADATA_HOST pointed at it, and the built example. Four runs at a 12s hang, the same four with tracing off as the control.
Your mutation work — reproduced, and the vacuous test is genuinely fixed
Test_buildExporter_dialsTheResolvedEndpoint is the right answer, and your reasoning for it is the part worth keeping: asserting on the startup log would not have worked, because the log reads the same variable the option does. The socket is the only observable that constrains it. The comment explaining why the wait needs its own timer is the kind of thing that stops someone "simplifying" it back into flakiness later — I ran it 8 times and the full suite under -race 3 times, all green.
The mutant from my last review is now caught. I re-ran the exact one — hardcode WithEndpoint to an invalid target, discarding cfg.Endpoint:
before this push: go test ./... -> ok (survived the whole 93.9% suite)
at 9c796a54b: go test ./... -> FAIL Test_buildExporter_dialsTheResolvedEndpoint
Test_buildExporter_redactsEndpointInError
I also ran two of my own beyond your three — disable the scheme check by making the branch unreachable, and drop RedactMessage from the constructor error. Both caught, 3 tests and 1 test respectively. Five mutants, five kills.
(Our failure counts differ on the shared three only because we mutated at different sites; the kills are what matter and they agree.)
One number does not reproduce. Coverage is 96.9%, not 97.3% — deterministic across six invocations (-race, plain, -coverpkg, GOWORK=off, three repeats). It changes nothing about the review; the tests are real and the mutation results are the stronger evidence anyway. Flagging it because this is the second time a coverage figure in this stack has been a few tenths high — 97.2% against a measured 95.4% on #4206 — and a number offered as evidence costs more later than the fraction of a point is worth. Worth checking what is producing it, since twice is a habit rather than a slip.
Finding 2 — you settled it, and the answer is that I was too generous
I said I was not claiming the PR was wrong. It was, and gcloud iam roles describe is what shows it: roles/cloudtrace.agent does carry telemetry.traces.write, so "NOT sufficient" was false in all four places including the copy-pasteable one. I still cannot run that command — my auth is expired, and it is unchanged — so I am accepting your output rather than verifying it. Publishing the three describe commands in the README is what makes that acceptable: the next reader re-checks in ten seconds instead of trusting either of us, and the hedge about the permission being Google's to change is the right amount of caution.
The serviceUsageConsumer scoping is better than what I asked for. I flagged it as missing; you found that it applies to user credentials rather than to an attached service account, which is a distinction I did not make and which the metrics README already had right.
Findings 1 and 3, and New 2 — closed
- Scheme rejected at startup. Your reasoning for rejecting rather than normalising is right and is the part I would not have written: stripping
https://would silently accepthttps://some-other-host:4317and hand it a Google OAuth token. One rule beats two behaviours. Confirmed live — row 2 above is a cleantracing is disabledwith the fix named in the message, the app still serves 200, and trace IDs are still generated, so correlation survives. go.modhistory now tells the real sequence. Directive kept.- 34/34 modules tidy, examples included — I ran it over
git ls-files '*go.mod'and got exit 0. Filing the CI re-scope separately is the right call; it is adevelopmentchange, not this PR's. withProjectIDnow keepsmergedwhen non-nil. Correct:Mergereturns the combined set alongside the error, so the old branch would have dropped every platform attribute.
Notes, not blocking
- The
x-goog-user-projectwarning is case-sensitive, so the README's promise is conditional.parseHeadersstores keys verbatim (pkg/gofr/otel.go:160-165, no lowercasing), and the check iscfg.Headers["x-goog-user-project"]— a case-sensitive Go map lookup.TRACER_HEADERS=X-Goog-User-Project=pfires nothing, whileREADME:62says "the exporter warns if you do". gRPC lowercases metadata on the wire, so the header still reaches Google; only the warning is silent. Inherited, not introduced —metrics/exporters/gcp/gcp.go:169is identical, so this belongs with the other cross-module follow-ups rather than here. production-tracing/page.md:43still reads "Ignored whenTRACER_URLhas a scheme" forTRACER_INSECURE, in the table that listsgcp. Forgcpa scheme is rejected, not ignored. Line 42 clarifies it two rows up, so this is cosmetic.warnMissingProjectruns beforeresolveEndpoint, so a rejected endpoint still emits a missing-project warning first. Harmless, slightly confusing ordering.errSchemeInEndpointis unexported, so a caller cannoterrors.Isit. Low impact — the registry degrades totracing is disabledeither way — but worth a thought if that sentinel is meant to be part of the contract.- One commit behind
development(2411b1804, unrelated router work). No conflict.
Checked and deliberately not flagging
Including the two places your approach beat mine — expand
- The builder-error path does what you said. Verified in the running app, not just in the source:
provider.go:130logs the already-redacted message and returns nil, so tracing is disabled and the app serves normally. Row 2 above is that path executing. - Trace IDs survive disabled tracing. Case 2 still emits
trace_idon/hello, so theNeverSamplecorrelation behaviour from #4206 is intact under this failure mode. That is the scar the oldotel.gocomment was about, and it still holds. RedactMessagecovers the escaped form.redact.goreplaces bothendpointand itsstrconv.Quoterendering, which is why your control-character case is caught — the SDK emits the quoted form, and a naivestrings.ReplaceAllon the raw value alone would have missed it.schemeOfis right on everything except leading whitespace. I probed 26 values:HTTPS://,HtTp://,grpc://,dns:///,unix://,a+b-c.d://all reject;1http://,//host,://host,host:443/v1?x=a://ball pass through, which is what the doc comment says it intends and I agree with — a stray://should not be reported as a scheme nobody wrote.- Lint 0 issues,
go vetclean, socket test 8/8, full suite-race3/3, CI 16 success / 1 skipped / 0 failures. - The exporter itself costs nothing per request, which I measured rather than assumed. 3000 requests to
/helloafter 200 warmup, same listener, back to back:otlpmean 48µs / p50 35µs / p99 122µs,gcpmean 41µs / p50 34µs / p99 111µs — identical within laptop noise, and correct by design since the exporter sits behind the BatchSpanProcessor and the request path only enqueues. Binary cost of the blank import is +94,016 bytes (+0.2%), smaller than it looks because a plain GoFr app already links 12 Google modules viacloud.google.com/go/pubsub; the import adds onlyotlptracegrpcand the two detector modules. For anyone not importing it, +0 bytes. - The example's own config is still right —
.envand README use schemeless forms throughout and explain whyTRACER_URLis left unset.
Evidence: example built as a binary and run six times against a connection-counting listener · strings.TrimSpace patch applied, rebuilt, both bypass cases re-run and fixed · 5 mutants, 5 kills · 34/34 modules go mod tidy -diff exit 0 · golangci-lint 0 issues · coverage measured 96.9% across six invocations · per-request latency and binary size measured against an otlp control · startup measured against absent, wedged and control metadata servers · worktree restored afterwards.
Two blockers. The first is one line and two test cases. The second is a deadline on one context — the parent fix across both modules is not this PR's job, only not shipping the hang to the traces path is. Everything below those is a note.
Adds pkg/gofr/traces/exporters/gcp, an optional module that exports spans directly to Google Cloud's Telemetry (OTLP) API using Application Default Credentials. On Cloud Run a GoFr service now gets traces into Cloud Trace with an IAM role alone — no key file, no Collector sidecar. A vendor exporter is needed rather than a TRACER_HEADERS value because Google's tokens expire in about an hour: a static header authenticates once and then silently stops. The per-RPC token source from ADC refreshes on its own. The module also sets the gcp.project_id resource attribute from the ambient credentials, falling back to GOOGLE_CLOUD_PROJECT, and warns when neither resolves. Google's guidance indicates spans may be routed by that attribute but does not pin it down for a plain Go gRPC exporter; setting it is correct either way, and it is what the registry's resource-detector seam exists for. Docs name roles/telemetry.tracesWriter. roles/cloudtrace.agent — which the issue asks for — is NOT sufficient: it authorizes the older Cloud Trace API, which this exporter never calls. Also: examples/using-gcp-traces with a Cloud Run deploy recipe, the module in go.work and dependabot's gomod globs, and gcp/TRACER_INSECURE entries across the tracing docs. The module carries a temporary `replace gofr.dev => ../../../../..` because no release yet contains pkg/gofr/traces/exporters; it is tracked for removal.
…replace in an issue Review follow-up. Both are about a number and a claim being checkable rather than asserted. The dependabot comment said 37 go.mod directories; there are 34 (`git ls-files '*go.mod' | wc -l`, now named in the comment so the next reader can re-run it). That comment's whole job is "the globs below must cover all of them", so an inflated count sends someone looking for three modules that do not exist. Every module does match a glob. The replace directive's removal is now tracked in gofr-dev#4210 rather than only in a // TODO:. It is a release-ordering constraint someone has to act on at tag time -- a tagged gofr.dev containing pkg/gofr/traces/exporters, then the bump, then the submodule tag -- and a go.mod comment is the weakest place to park that.
The registry landed on development with golang.org/x/oauth2 v0.37.0 (and the matching sys/term/text bumps), so this module's pinned v0.36.0 no longer matches 'go mod tidy' and the submodule tidiness check fails.
…t the endpoint
otlptracegrpc.WithEndpoint stores its argument verbatim as the gRPC target, so
a scheme-bearing TRACER_URL is invalid ("too many colons in address") and fails
at export rather than at startup -- the app boots healthy, logs that it is
exporting, and drops every span. Reject it in buildExporter instead: this
destination is always TLS on 443, so a scheme is a value to refuse, not to
interpret. The TRACER_URL row in the configs reference, which promised that a
scheme selects the transport for gcp too, is corrected in the same pass.
Also redact the endpoint in both places it reaches a log: the startup line via
RedactURL, and otlptracegrpc.New's own error -- which quotes it back as
parse "dns:///<endpoint>" -- via RedactMessage.
Tested with an ephemeral listener rather than the startup log alone: the log
reads the same variable the option does, so it cannot tell that WithEndpoint
received the resolved value. A connection arriving at the listener can.
…e history roles/cloudtrace.agent does include telemetry.traces.write, so the claim in four places that it "is not sufficient" was wrong rather than merely unverified. Say what gcloud iam roles describe reports: the endpoint checks telemetry.traces.write, tracesWriter is the least-privilege role carrying it, and telemetry.writer and cloudtrace.agent carry it too. Adds the serviceUsageConsumer note the metrics twin already documents, and carries the remaining "gcp takes a schemeless host:port" notes into the tracing guide, the observability quick-start and the example README. The replace directive stays -- no release contains pkg/gofr/traces/exporters yet (v1.61.0 predates gofr-dev#4206) -- but its history was wrong: the metrics module did ship the same replace in 5e00b8c and dropped it in ceb5039 (gofr-dev#3926). Also tidies examples/using-gcp-traces, 72 lines stale and invisible to CI because the tidiness gate is scoped to pkg/.
…rtup path Two failures from review round 4, both of which boot healthy and then do the wrong thing quietly. resolveEndpoint used cfg.Endpoint raw. A leading space fails isSchemeRune at position 0, so schemeOf reports no scheme and a scheme-bearing value walks straight through the guard added last round; a trailing space needs no scheme at all and leaves a target whose port parses as a service name. Nothing upstream trims -- config.Get is a bare os.Getenv, and the plain environment path this exporter exists for has none. TrimSpace once, before both branches. Registering the first trace resource detector put a socket on a startup path whose context has no deadline (exporters.Build takes context.Background(), pkg/gofr/otel.go:74). A metadata server that accepts and never replies therefore held boot open for as long as it hung, before the HTTP server bound its port. The deadline is on the wait rather than on the context, because a context cannot work for either caller: contrib/detectors/gcp discards its context argument outright, and oauth2/google keeps the one it is given inside the returned Credentials for every later token refresh, so a WithTimeout there would expire export about an hour in. awaitWithin bounds both at 5s, the metadata client's own budget. A detector timeout degrades to a partial resource and keeps tracing; a credentials timeout disables tracing. Both bind the port. The parent context.Background() is untouched: it is shared with the metrics path and belongs in its own PR off development.
9c796a5 to
9f44f0b
Compare
|
Both blockers are fixed at Row 4 was the right one to lead with. It is not a hole in the scheme check — the endpoint was never normalised, and the scheme check is just the most visible thing falling through that gap. Tests: four table cases (padded scheme, tab/newline-padded scheme, padded schemeless, whitespace-only) plus a padded subtest on the socket test. The trailing-space case is specifically one a log assertion cannot catch, for the same reason the vacuous endpoint test could not be fixed with a log line — it reads the variable the dial reads. On the second blocker: your proposed fix would have been inert, and the obvious variant is worseYou asked for "a deadline on the context the detector is given, or on the detector itself". I went to add the first and it does nothing: // contrib/detectors/gcp@v1.46.0 detector.go:35
func (d *detector) Detect(context.Context) (*resource.Resource, error) {
if !metadata.OnGCE() { // the context-free variant, i.e. context.Background()The parameter is unnamed — the context is discarded outright. A The variant that looks safer is worse than inert. So the deadline is on the wait, not on the context: The abandoned goroutine is the cost of a library that cannot be cancelled. One goroutine, buffered channel so it never blocks on a receiver that has gone, ends when the server answers or the socket dies. Degradation is deliberately asymmetric, and I checked both against The parent The stub ignores its context on purpose
Three mutants run:
That last one is worth reading as a measurement rather than a test result: against a listener that accepts and never replies, the unbounded lookup sat for 31.2s. Your 11.6s/19.6s numbers were the detector; the credentials path is its own, longer hang. NotesTaken: the Declined, with reasoning rather than silence: Deferred as cross-module, as you framed it: the Coverage98.0% on this push — identical across repeats, under On your wider point: you are right that a number offered as evidence costs more later than the fraction of a point is worth, and twice is a habit. I cannot diagnose 97.3% after the fact — the code it was measured against no longer exists, and re-deriving it now would be reconstruction, not verification. What I can change is the practice: coverage figures from here are quoted from Also: |
aryanmehrotra
left a comment
There was a problem hiding this comment.
Approving at 9f44f0b54. Both blockers are fixed, and I checked them the way they were found — mutants and a running app, not a reread of the diff.
You were right and I was wrong about the second fix, and the reason matters more than the outcome. I asked for "a deadline on the context the detector is given." I verified your three claims at the pins and all three hold:
contrib/detectors/gcp@v1.46.0 detector.go:35takescontext.Contextunnamed and discards it, andcompute/metadata@v0.9.0 metadata.go:125-127hasOnGCE()callOnGCEWithContext(context.Background())— so the context is thrown away twice over. AWithTimeoutthere bounds nothing.oauth2@v0.37.0 google/google.go:171,223captures the context incfg.TokenSource(ctx)for every later refresh, whiledefault.go:269'scomputeTokenSourcedoes not — so the "safer" variant would have been clean on GCE and expired an hour into every run off it.compute/metadata@v0.9.0 metadata.go:74-83setsc.Timeout = 5 * time.Second, sometadataTimeoutborrows a budget rather than inventing one.
What I asked for would have shipped a fix that passes review, passes a test written against a well-behaved stub, and still hangs in production. Putting the deadline on the wait rather than the context is the right shape, and hangingDetector discarding its own context on purpose is what stops the test from certifying a fix that does nothing. That is the same vacuity class as the endpoint table last round, caught by you this time.
Blocker 1 — the whitespace bypass
Mutant: drop strings.TrimSpace from resolveEndpoint → killed, by Test_resolveEndpoint/a_padded_scheme-bearing_value_is_still_rejected among others.
Live, examples/using-gcp-traces unmodified, three values an operator could actually set:
TRACER_URL |
Result |
|---|---|
" https://telemetry.googleapis.com:443 " |
rejected at startup: TRACER_URL must be a schemeless host:port, not "https://…": … carries a "https" scheme and Google's OTLP ingest is always TLS on 443; tracing is disabled |
" " |
trims to empty → default endpoint path, no bypass |
" telemetry.us-central1.rep.googleapis.com:443 " |
accepted, padding removed |
The ordering change shows up too: the padded-scheme run does not log the missing-project warning, because resolveEndpoint now fails before warnMissingProject runs. That was the note, and it behaves as described.
Blocker 2 — the startup hang
Mutants, each against the test written for it:
| Mutant | Result |
|---|---|
| detector call unbounded | killed — panic: test timed out after 45s, Test_cachingDetector_Detect_isBoundedByTheStartupDeadline |
| ADC call unbounded | killed — adc.get blocked for 31.06s; the startup deadline did not bound it |
drop TrimSpace |
killed — see above |
Your 31.17s reproduces at 31.06s. Live worst case — metadata server wedged (accepts, never answers) and no key file, so both the detector and ADC must reach it:
run 1 16:46:30.607 -> 16:46:40.611 10.004 s
run 2 16:46:53.645 -> 16:47:03.652 10.007 s
run 3 16:47:04.333 -> 16:47:14.337 10.004 s
Exactly 2 × metadataTimeout, reproducible to 3 ms, and the port binds. Degradation is asymmetric as you describe: the detector timeout logs traces: resource detection was incomplete and tracing continues; the ADC timeout logs failed to initialize … tracing is disabled. Both serve traffic.
For scale, I measured the unfixed twin this week while reviewing #4267: metrics/exporters/gcp under the same wedge binds its port after 94.67 s, split 62.66 s in the detector and 32.01 s in ADC. That is what this PR is 10 s instead of. I have filed it, with the measurement, as #4293 — your awaitWithin shape transfers there nearly verbatim once this lands.
Gates
go vet ./... clean
golangci-lint run ./... 0 issues
go mod tidy -diff, all 34 modules 0 untidy
CI at 9f44f0b54 16 pass, 1 skipping, 0 fail
go test -count=1 -race ./... ok
The coverage number — I can diagnose this one, and the answer is useful
You wrote that you could not diagnose 97.3% after the fact because the code no longer existed. This one still exists, so I looked properly rather than reporting a mismatch again.
pkg/gofr/traces/exporters/gcp has 81 coverable statements at 9f44f0b54. Coverage is a ratio of integers, so go test -cover can only ever print k/81:
79/81 = 97.5% <- what eight invocations give me
80/81 = 98.8%
81/81 = 100.0%
98.0% is not on that lattice. No test selection, no -race, no -covermode, no GOWORK=off, no flake produces it — the number cannot come out of go test -cover on this package at this commit at all. Eight invocations (plain, -race, atomic, count, set, GOWORK=off, -coverpkg, and from the repo root by package path) all print 97.5%, and go tool cover -func agrees. Uncovered: withProjectID 85.7%, buildExporter 94.1% — two statements.
The same test on #4267, where the claim was 90.0% → 95.3%:
| commit | statements | reachable near the claim | claimed |
|---|---|---|---|
272478ab9 |
108 | 94.4% / 95.4% / 96.3% | 95.3% |
7129f0d54 |
113 | 94.7% / 95.6% / 96.5% | 95.3% |
Unreachable there too. So this is not a measurement that drifts — it is a number arriving from somewhere other than the command it is attributed to. That is a much more fixable problem than "be careful", and it is the last open thread between us: paste the literal go test -cover line next time and the whole category disappears. Nothing about this review turns on it.
Notes, closed
Taken, and each is better for it: the TRACER_INSECURE row now says gcp rejects rather than ignores a scheme; warnMissingProject moved after resolveEndpoint; the example README carries a "Startup cost" section that documents the two metadata calls and the asymmetric degradation — which is exactly the thing an operator needs and cannot infer.
Declined with an argument, which is the right way to decline. errSchemeInEndpoint staying unexported is correct: an exported symbol cannot be withdrawn, the registry degrades to tracing is disabled either way, and there is no caller wanting to errors.Is it. Export it the day one exists.
Deferred correctly. The case-sensitive x-goog-user-project check is shared verbatim with metrics/exporters/gcp/gcp.go:169; moving both together in one PR is the right shape, and I would rather see it there than half-done here.
Merge-ready from my side. It is 2 commits behind development (e95c11759, a313baf7f) — worth a rebase before merging, and #4267 landing underneath it means the two gcp modules are finally converging rather than diverging.
Umang01-hash
left a comment
There was a problem hiding this comment.
Reviewed deeply and verified everything at head c122ee2 — built, vet, gofmt, -race, repo golangci-lint, 97.5% coverage, and ran the example E2E (boots + degrades cleanly with no creds; warnings, redacted endpoint log, correlation IDs, and export auth errors all surface correctly). Confirmed against Google's docs that telemetry.googleapis.com keyless OTLP ingest + roles/telemetry.tracesWriter are accurate.
Solid, self-contained, additive — plugs into the existing exporter registry (mirrors the metrics/gcp sibling), no core changes needed, no breaking changes. LGTM. Two non-blocking nits below.
aryanmehrotra
left a comment
There was a problem hiding this comment.
Re-reviewed at 960e3c74a, which is now even with development (609b381b9).
Both blockers from my last round are closed, and I confirmed each one the same way I raised it — by running the example, not by reading the diff.
Blocker 1 — TRACER_URL padding: closed, live
Same four rows as last time, same built binary, same fake ADC:
TRACER_URL |
before (9c796a54b) |
now (960e3c74a) |
|---|---|---|
127.0.0.1:34317 |
INFO exporting … |
INFO exporting … at 127.0.0.1:34317 |
https://127.0.0.1:34318 |
ERROR … tracing is disabled |
ERROR … tracing is disabled |
␣https://127.0.0.1:34319 |
INFO exporting …, 0 dials |
ERROR … must be a schemeless host:port … tracing is disabled |
127.0.0.1:34320␣ |
INFO exporting …, 0 dials |
INFO exporting … at 127.0.0.1:34320 (trimmed) |
Rows 3 and 4 are the two silent-drop cases, and both now behave. Reverting the strings.TrimSpace takes 6 subtests across 2 tests with it, including the socket-level Test_buildExporter_dialsTheResolvedEndpoint/padded — so this is guarded, not merely fixed.
Blocker 2 — the startup hang: closed, and the improvement is measured
Example binary, a listener that accepts and never answers as GCE_METADATA_HOST, timed from exec to the first 200 on /hello:
| scenario | time to first 200 |
|---|---|
| metadata absent (connection refused) | 45 ms |
| wedged metadata server — deadline removed (control) | 64,689 ms |
| wedged metadata server — at this head | 5,030 / 5,031 ms |
| wedged, tracing off (control) | 24 / 46 ms |
5.03s is exactly one metadataTimeout: with GOOGLE_APPLICATION_CREDENTIALS set, FindDefaultCredentials resolves from the file and never reaches metadata, so only the detector's bound is spent. The 2*metadataTimeout worst case in the comment is the right ceiling for the case where it does.
Your citations check out at the pinned versions, which matters because the design rests on them:
| claim | verified |
|---|---|
| detector discards its context | contrib/detectors/gcp@v1.46.0 detector.go:35 — func (d *detector) Detect(context.Context), unnamed parameter, calls the context-free metadata.OnGCE() |
| 5s is the metadata client's own budget | compute/metadata@v0.9.0 metadata.go:83 c.Timeout = 5 * time.Second over :74 Dialer{Timeout: 2s}, and :66 builds the default client with enableTimeouts=true — so it is the real default, not a branch nothing takes |
| oauth2 keeps the context for refresh | oauth2@v0.37.0 google/google.go:171 and :223 both return cfg.TokenSource(ctx) — the context is retained by the returned source |
Blocking — one of the two deadline tests does not guard its deadline
I mutated both bounds. One mutant dies; the other walks through CI.
| mutant | full suite | in isolation |
|---|---|---|
drop awaitWithin around the platform detector |
FAIL (suite hangs, panic: test timed out after 2m0s) |
FAIL |
drop awaitWithin around the ADC lookup |
ok … 4.774s ← survives |
FAIL — adc.get blocked for 31.05s |
Run under -v, Test_adc_get_isBoundedByTheStartupDeadline reports --- PASS … (0.00s) in the full suite. A test whose whole subject is a 5-second bound cannot be exercising it in zero milliseconds: FindDefaultCredentials is returning before it ever contacts the wedged listener, and the surviving err != nil assertion is satisfied by "no credentials found" rather than by the timeout.
The cause is Test_cachingDetector_Detect_addsProjectID (gcp_test.go:508). It is the one test that builds a cachingDetector with no platform, so platformDetector() hands back the real gcpdetect.NewDetector(), whose Detect calls metadata.OnGCE() — and that answer is memoized for the life of the process (compute/metadata@v0.9.0 metadata.go:118 onGCEOnce sync.Once, :134; the doc comment says "memoized for better performance"). It runs with no metadata host set, caches false, and every later FindDefaultCredentials in that process short-circuits before the metadata step.
I isolated it rather than guessed — every other test calls writeADC(t) and resolves from the file, so none of them reach OnGCE:
go test -run 'Test_buildExporter_endpoint|Test_adc_…' -> FAIL, 31.05s
go test -run 'Test_buildExporter_redactsEndpointInLog|Test_adc_…' -> FAIL, 31.79s
go test -run 'Test_buildExporter_warnsOnIgnoreListed…|Test_adc_…' -> FAIL, 31.77s
go test -run 'Test_warnMissingProject|Test_adc_…' -> FAIL, 30.89s
go test -run 'Test_cachingDetector_Detect_addsProjectID|Test_adc_…' -> PASS, 0.00s <-- this one
The fix is one line, and I verified it rather than proposing it. Give that test the same injected seam the rest of the file uses:
d := &cachingDetector{adc: resolvedADC(""), platform: offGCEDetector{}}
// offGCEDetector reproduces what contrib/detectors/gcp does off Google Cloud at
// the pinned version: metadata.OnGCE() is false, so Detect returns (nil, nil)
// without touching the network (detector.go:36-37).
type offGCEDetector struct{}
func (offGCEDetector) Detect(context.Context) (*resource.Resource, error) { return nil, nil }With that applied:
unmutated, full suite -> ok 8.939s
ADC mutant, FULL SUITE -> FAIL adc.get blocked for 31.53s <-- the kill is restored
Why I am blocking on a test. The production code is correct — I measured it at 5.03s against a 64.7s control. But these two tests exist so that the bound cannot be removed later without CI noticing, and today one of them would not notice. That is the same reason you rewrote Test_buildExporter_dialsTheResolvedEndpoint last round rather than asserting on the log line, and the reasoning you gave for it applies here unchanged.
It also removes a real network probe from a unit test as a side effect: on a runner with no metadata server, addsProjectID currently does a live OnGCE() DNS-and-HTTP attempt.
Notes, not blocking
The "detector fails off Google Cloud" comment is wrong at the pin. gcp.go:196-199 and the comment at gcp_test.go:510 both say the platform detector fails off Google Cloud and that the error is returned so the SDK can report a partial resource. At contrib/detectors/gcp@v1.46.0 detector.go:36-37 it returns (nil, nil) — no error at all. The ErrPartialResource path at :152-153 is the on-GCE partial case. The code does the right thing either way (withProjectID already tolerates a nil resource, and addsProjectID discards the error with _), so this is a comment correction, not a behaviour one.
Test_buildExporter_warnsOnIgnoreListedQuotaHeader kept the old name. The message it asserts now says the header is forwarded and is not honored, which is the wording Umang's thread settled on. The test name still says "IgnoreListed".
The quota-header warning is still case-sensitive — cfg.Headers[quotaProjectHeader] against a map parseHeaders fills verbatim (pkg/gofr/otel.go:141-169, no lowercasing), so TRACER_HEADERS=X-Goog-User-Project=p warns nothing while gRPC still forwards it. Unchanged from my last review and still inherited — metrics/exporters/gcp/gcp.go:169 is identical — so it belongs with the cross-module follow-ups.
errSchemeInEndpoint is still unexported, so an external caller cannot errors.Is it. Low impact; the registry degrades to tracing is disabled regardless.
The production-tracing row I flagged is fixed — page.md:43 now reads "except for gcp, which rejects a scheme at startup rather than ignoring it". Good.
Everything else I ran, all clean
| gate | result |
|---|---|
go build / go vet |
clean |
go test -count=1 |
pass, 3/3 repeats |
go test -race |
pass |
| coverage | 97.5% |
golangci-lint (repo config, GOWORK=off) |
0 issues |
gofmt -l over the module + example |
empty |
| dependabot module count | git ls-files '*go.mod' | wc -l = 34, matches the comment |
go.work |
new module present at line 37 |
CI at 960e3c74a |
15 success, 1 skipped, 0 failures |
Evidence: example built as a binary and run in 8 scenarios · boot timed against absent / wedged / wedged-with-tracing-off metadata servers, 2 runs each, plus a deadline-removed control · 4 TRACER_URL cases re-run live · 3 mutants, 2 kills, 1 survivor isolated to its cause across 5 ordering probes · proposed fix applied and the survivor re-killed in the full suite · three dependency claims read at the pinned versions in GOMODCACHE · worktree restored, git status clean.
One line and a six-line helper. Everything above it is closed.
`pkg/gofr/traces/exporters/gcp` and `examples/using-gcp-traces` landed on development after this branch was cut (#4207), so they still pin the pre-bump versions. `go mod tidy -diff` fails there once this branch's bumps are in the graph. Running `go mod tidy` in both takes otlptracegrpc to 1.45.0 in a seventh manifest, which #4295 could not cover because it did not exist yet — GHSA-8wmf-6v46-5gfg is now closed everywhere it applies. `otlptracegrpc.WithEndpointURL` is not affected by the 1.45.0 URLPath change that keeps otlpmetrichttp pinned at 1.44.0: the gRPC exporter ignores URLPath outright (otlpconfig/options.go:291 at v1.45.0), and `gcp.go:286` uses `WithEndpoint` anyway.
`pkg/gofr/traces/exporters/gcp` and `examples/using-gcp-traces` landed on development after this branch was cut (#4207), so they still pin the pre-bump versions. `go mod tidy -diff` fails there once this branch's bumps are in the graph. Running `go mod tidy` in both takes otlptracegrpc to 1.45.0 in a seventh manifest, which #4295 could not cover because it did not exist yet — GHSA-8wmf-6v46-5gfg is now closed everywhere it applies. `otlptracegrpc.WithEndpointURL` is not affected by the 1.45.0 URLPath change that keeps otlpmetrichttp pinned at 1.44.0: the gRPC exporter ignores URLPath outright (otlpconfig/options.go:291 at v1.45.0), and `gcp.go:286` uses `WithEndpoint` anyway.
… and correct the docs akshat-kumar-singhal's blocking finding was right, and the measurement is unambiguous. #4206/#4207/#4247/#4249 moved the OTLP trace exporter to pkg/gofr/traces/exporters/otlp.go, where it self-registers and imports otlptracegrpc. This PR's gofr_nootlp split code out of an otel.go that no longer holds it, so the tag removed the metrics half and nothing else: otlptrace packages linked, before this commit default: 10 gofr_nootlp: 10 <- the tag removed none of them after default: 10 gofr_nootlp: 0 The constraint now sits on traces/exporters/otlp.go, with otlp_disabled.go registering "otlp" and "jaeger" to a builder that fails naming the tag. Registering rather than omitting the names is deliberate: Build's unknown-exporter path would report TRACE_EXPORTER=otlp as a name it does not recognize, which reads like a typo and sends an operator to check their spelling. The stub tells them the name is right and the binary does not carry it. Build's nil-exporter path then installs the NeverSample provider, so the service still starts with working correlation IDs. The obsolete pkg/gofr/otlp_trace*.go files are dropped. The metrics half (metrics/exporters/otlp_transport*.go) is unchanged and still correct. **CI now checks the three new tags.** gofr_nootlp and gofr_nodgraph get their own patterns; gofr_nogrpc is checked against the combined tag set because grpc is pinned by the OTLP exporters, the Dgraph protos and cloud.google.com/go as well as by the server -- alone it takes 4 of 86 packages, so a bare "still linked" assertion would fail for reasons unrelated to the tag working. All six checks pass locally. **Three doc corrections, all as reported:** - the all-six build command dropped gofr_nogrpc, because ./... cannot build with it in this repo; the exception is stated where the doc used to claim the tags never remove API - gofr_nootlp's settings are TRACE_EXPORTER=otlp|jaeger and METRICS_EXPORTER=otlp. GoFr never reads OTEL_EXPORTER_OTLP_ENDPOINT, and TRACER_URL still works for zipkin and gofr - gofr_nodgraph is broader than "Dgraph migrations": migration.go chains the Dgraph migrator whenever c.DGraph is set, so a service with a Dgraph datasource and only SQL migrations exits at app.Migrate Counts re-measured on darwin/arm64, Go 1.26.3: 828 default, 786 nootlp, 784 nosqldrivers, 808 nographql, 818 nogrpc, 826 nodgraph, 411 with all six. redact_test.go's four OTLP cases take an otlpTraceLinked skip, mirroring container.pubsubBackendsLinked. Verified: build and vet clean for default, each tag and all six; tests pass untagged and under all six; typos, gofmt and golangci-lint clean.
Description:
Closes #4204.
Adds
pkg/gofr/traces/exporters/gcp, an optional module that exports spans directly to Google Cloud's Telemetry (OTLP) API using Application Default Credentials. On Cloud Run a GoFr service now gets traces into Cloud Trace with an IAM role alone — no key file, no Collector sidecar:A vendor exporter is needed here rather than a static
TRACER_HEADERSvalue because Google's tokens expire in about an hour: a static header authenticates once and then silently stops working. The per-RPC token source from ADC refreshes on its own.The module also sets the
gcp.project_idresource attribute from the ambient credentials, falling back toGOOGLE_CLOUD_PROJECT, and warns when neither resolves. Google's guidance indicates spans may be routed by that attribute but does not pin it down for a plain Go gRPC exporter; setting it is correct either way, and it is exactly what the registry's resource-detector seam from PR 2 exists for.Docs name
roles/telemetry.tracesWriter.roles/cloudtrace.agent— which the issue asks for — is not sufficient: it authorizes the older Cloud Trace API, which this exporter never calls.Also included:
examples/using-gcp-traceswith a Cloud Run deploy recipe, the new module ingo.workand in dependabot's gomod globs, andgcp/TRACER_INSECUREentries across the tracing docs.Breaking Changes (if applicable):
None. This is a new opt-in module;
gofr.dev's owngo.modgains no Google dependencies. ExistingTRACE_EXPORTERvalues are untouched.Additional Information:
pkg/gofr/traces/exporters/gcp/go.mod—golang.org/x/oauth2/googlefor ADC and the OTLP gRPC exporter. Users who do not import the module pay nothing.replace gofr.dev => ../../../../..because no taggedgofr.devrelease yet containspkg/gofr/traces/exporters. It must be removed once feat(tracing): add a trace exporter registry, resource and shutdown flush #4206 ships in a release; that is tracked in gcp traces submodule: drop thereplace gofr.devonce a release contains pkg/gofr/traces/exporters #4210 and referenced from the// TODO(#4210):in the module'sgo.mod. An earlier revision of this description said it was "tracked for removal" when no issue existed — raised in review, now true.git ls-files '*go.mod' | wc -l) named alongside so the number stays checkable — that comment's job is to assert the globs cover every module, so a wrong count sends the next reader hunting for modules that do not exist.gcp.project_idrouting question are documented from Google's own docs, and confirming them end to end needs a real Cloud Run deploy ofexamples/using-gcp-traces.Checklist:
goimportandgolangci-lint.