Phase 10: notify and APM spans - #40
Conversation
A finished span is dropped from the bridge registry, and the device scenario records a notification plus a transaction in the report. Co-authored-by: Cursor <cursoragent@cursor.com>
Codegen queues a void TurboModule method, so a setAttribute then finish in one turn released the handle before the SDK saw the attribute. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Stale comment
Deep code review: Phase 10 notify and APM spans
The PR's intent is clear:
notifyis a fire-and-forget relay, and spans are objects with a lifetime that the bridge must not leak. The setter follow-up (boolean returns so codegen stays on the JS thread) is the right fix for thesetAttributethenfinishin one turn bug, and the device cases for the attribute landing in the report are the evidence that path needed.I reviewed the JS facade, both native registries, invalidate, the TurboModule spec, and the surrounding JSON/thread patterns this repo already uses. I did not run the unit or device suites in this environment.
Findings
- P2 Native
finishonly drops a handle whenisFinished()is already true; JS then throwsE_SPAN_HANDLE_DEADon the firstfinish()if that handle is missing from the returned list. That combination can leak the native span and makefinish()unrecoverable.- P2
setAttributesendsJSON.stringify(value)instead of the well-formed JSON transport this package already uses. A lone surrogate is rejected by iOSNSJSONSerialization, the setter's boolean is ignored, and JS still caches the attribute.- P2 The new span registry is mutated on the span call path with no lock, while
invalidate()clears it from another queue (iOS) / another thread (Android). This module already documents that overlap forCreatedReports.- P2 Android
startTransaction/startSpan/spanStartChildNPE if the SDK returns null. iOSadoptSpan:already returns an empty handle for nil;getActiveSpanis the only Android path that does.Overall risk: Medium
Merge recommendation
Request changes. The APM surface is new and the leak/teardown issues sit on the lifetime contract this PR is supposed to pin. None of these look like an immediate crash on the happy path the device tests covered (APM on, finish after setters, no reload), but 1 and 3 are real failure modes around APM-off / no-op spans and RN instance teardown.
Most important to fix
- Always drop the handle
finishwas called with, then also drop any other retained span that is now finished. JSfinish()must not throw on the first call.- Put span-attribute values through the same well-formed JSON path as
notifyfields /startTransactionattributes, and do not update the JS cache when native returns false.- Serialize
SpanHandles/BGSRNSpanHandlesagainstinvalidate()the wayCreatedReportsalready does (iOS: main-queuereleaseAll, or a lock; Android: synchronize the registry). Do not lazily recreate the iOS registry after_spanHandles = nil.What looks solid
- Boolean setter returns, with a spec-source lock and a device check that the child attribute actually reached the capture.
- Handle identity reuse so
getActiveSpan()returns the same JS object.notifyvalidation (empty title, field types, extra args, urgent) matches the rest of the facade.- Native unit tests pin "a finished span leaves the registry" for the Fake that does set
isFinished.Sent by Cursor Automation: Bugsee code review
A no-op span can stay unfinished, and the first finish then threw instead of releasing it. Attributes also wait for the native boolean, and the registry lock covers invalidate. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Stale comment
Re-review:
1547bf2addresses the prior P2sChecked
1547bf2against the four findings from the last pass. The author’s thread replies match the code.
Finding Verdict Native finishonly droppedisFinished()entriesFixed. The called handle is removed first ( releaseFinished(called)/dropCalled:), then other finished spans are swept.aSpanThatStaysUnfinishedIsStillReleased/testASpanThatStaysUnfinishedIsStillReleasedpin a no-op that leaves the flag down.JS first finish()threwE_SPAN_HANDLE_DEADFixed. First call marks dead and returns; a later call is what throws ( the first finish returns even when native omits the handle).setAttributeused rawJSON.stringifyand cached on failureFixed. Values go through encodeBridgeJson; the JS cache updates only when native returns true (a lone surrogate is well-formed, and a refused attribute is not cached).Registry vs invalidate()Fixed. Android methods are synchronized; iOS usesos_unfair_lock,_spansRetired, andclosedRegistryso the getter cannot allocate a live map after teardown (testAClosedRegistryAdoptsNothing).Android NPE on a null SDK span Fixed. startTransaction/startSpan/spanStartChildreturnnoSpan();retain(null, …)is""(aNullSpanIsNotRetained).No new high-signal defects in this delta. I did not run the unit or device suites in this environment.
Overall risk: Low
Merge recommendation
Approve. The lifetime, JSON, teardown, and null-span holes from the last review are closed. Remaining work is ordinary (device confirmation of the no-op/
finishpath if you want it, iPhone XS still unrun per the PR body).Sent by Cursor Automation: Bugsee code review
There was a problem hiding this comment.
Stale comment
Deep code review: Phase 10 notify and APM spans (re-review)
Re-reviewed 1547bf2 (prior review was 88b23c7). The follow-up commit matches the five P2s: native
finishalways drops the handle it was called with (even whenisFinishedstays false), JSfinish()returns on that first call,setAttributeusesencodeBridgeJsonand updates the cache only when native returns true, both registries serialize againstinvalidate(iOSclosedRegistryafter retire; Androidsynchronized), and a null SDK span isnoSpan()rather than an NPE. I checked those paths against the JS facade, both native registries, invalidate, and the unit tests (Sticky/BGSRNStickySpan, closed registry, null retain, omitted-handlefinish). I did not run the unit or device suites in this environment. GitHub CI on this head: Android, iOS unit, and iOS simulator e2e already green;lint, typecheck, unit, mutationwas still pending at review time.Findings
No remaining P0–P3. The five prior P2s are fully addressed.
Overall risk: Low
Merge recommendation
Safe once CI is green on 1547bf2.
Most important to fix
Nothing blocking. Residual, not a finding: iPhone XS (KRSFT) was still running BareExample, so that handset was not run; Android/iOS simulator device cases for the review fix are recorded in the plan.
What looks solid
- Boolean setter returns (spec-source lock) plus the device check that a child
setAttributeactually landed in the capture.- Handle identity reuse so
getActiveSpan()is the same JS object.notifyvalidation (empty title, field types, extra args, urgent) matches the rest of the facade; omitted severity still crosses as0(Android null / iOS unset).- Native tests now cover the no-op that leaves
isFinishedfalse, and JS covers the firstfinishwhen native returns[].Sent by Cursor Automation: Bugsee code review
A few checks were redundant with a later one, so a test could not kill them; those are gone. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Stale comment
Re-review:
670f63c(mutation coverage)Checked 670f63c against 1547bf2. The delta is the Stryker break from the previous CI run (92.83% < 95%): tighter tests in
span.test.ts/notify.test.ts, plus equivalent simplifications inspan.tsso redundant branches are no longer mutants.Production behavior is unchanged from the last pass:
assertTextstill rejects empty/whitespace viatrim().Number.isFinitewithouttypeofis the same finite-number gate.parseAttributesstill yields an empty map for bad JSON,null, and arrays (Object.entriesonnullhits the existingcatch).- First
finish()still marks dead even when native returns[]; a later call is what throws.- Native registries,
encodeBridgeJson, andnoSpan()from 1547bf2 are untouched.The five prior P2s remain fully addressed. No new P0–P3. I did not run Jest or Stryker in this environment (no workspace install); GitHub CI
lint, typecheck, unit, mutationwas still in progress on this head at review time (run 37079165154).Findings
No remaining P0–P3.
Overall risk: Low
Merge recommendation
Approve. Safe once CI is green on 670f63c, in particular the mutation job that failed on 1547bf2.
Most important to fix
Nothing blocking. Residual, not a finding: wait for
lint, typecheck, unit, mutationon this head; iPhone XS was still unrun in the PR body.What looks solid
- Tests now pin the exact error strings, omitted vs empty
notifyfields, dead-handle reuse of a handle id,getActiveSpanidentity, and the no-span / missing-child paths Stryker had been surviving.- The
span.tsedits delete only checks that a later condition already covered.Sent by Cursor Automation: Bugsee code review
There was a problem hiding this comment.
Deep code review: Phase 10 notify and APM spans (re-review)
Re-reviewed 670f63c (prior review was 1547bf2). This head is the mutation-coverage follow-up after CI lint, typecheck, unit, mutation failed on 1547bf2 at 92.83 (span.ts 65.23, notify.ts 74.29; threshold 95). The production contract is the same one that closed the five P2s: native finish always drops the called handle (even when isFinished stays false), JS finish() returns on that first call, setAttribute uses encodeBridgeJson and caches only when native returns true, both registries serialize against invalidate (iOS closedRegistry after retire; Android synchronized), and a null SDK span is noSpan() rather than an NPE.
I re-read the JS facade, both native registries, invalidate, the TurboModule spec, the JSON transport, and the unit/e2e coverage. The 670f63c edits in span.ts (drop typeof number next to Number.isFinite, let parseAttributes treat JSON null via Object.entries throwing, stop defaulting attributesJson to '{}', always markDead on the first finish) are equivalent to the previously reviewed behavior, not a new lifetime hole. I did not run the unit or device suites in this environment (node_modules is not installed here). GitHub CI on this head: ios unit, ios (spm), and RN compat already green; android, iOS cocoapods, iOS simulator e2e, and mutate were still pending at review time.
Findings
No remaining P0–P3. The five prior P2s are still fully addressed. The new tests pin the paths mutate actually killed (finish when native returns [] then reuses the handle, refused attributes, missing child, dead-handle later calls, wire-field drops).
Overall risk: Low
Merge recommendation
Safe once CI is green on 670f63c, especially mutate (that is the job this commit exists to clear).
Most important to fix
Nothing blocking. Residual, not a finding: iPhone XS (KRSFT) was still running BareExample, so that handset was not run.
What looks solid
- Boolean setter returns (spec-source lock) plus the device check that a child
setAttributeactually landed in the capture. - Handle identity reuse so
getActiveSpan()is the same JS object; a later native start with a released handle is a new JS object. notifyvalidation (empty title, field types, extra args, urgent) matches the rest of the facade; omitted severity still crosses as0(Android null / iOS unset).- Native tests still cover the no-op that leaves
isFinishedfalse, and JS now covers the firstfinishwhen native returns[]plus the mutation-killed branches around wire parsing and dead handles.
Sent by Cursor Automation: Bugsee code review


Summary
notifyqueues a persisted notification.startTransaction,startSpan, andgetActiveSpanhold native spans untilfinishreleases them, including children the SDK has already finished.finish. AsetAttributethenfinishin the same turn reaches the SDK, and the device test requires that attribute on the child span.Test plan
aFinishedSpanIsReleasedAMRJCP4718402860Debug:notify-f70ead5fa74c/txn-f70ead5fa74c, child attributes heldattr-f70ead5fa74c6FA9B3E8-26C7-4232-AA2C-537D9DF32957Debug:notify-c88c83270b1d/txn-c88c83270b1d, same attributeMade with Cursor