From 2b249976f709551f828e52a663fdbc5f8b3aea76 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 14 Sep 2026 17:12:11 +0200 Subject: [PATCH 1/2] docs: propose shared aggregate member inspection budget --- .../.openspec.yaml | 2 + .../design.md | 28 +++++++++ .../proposal.md | 23 +++++++ .../specs/exception-event-metadata/spec.md | 61 +++++++++++++++++++ .../tasks.md | 9 +++ 5 files changed, 123 insertions(+) create mode 100644 openspec/changes/document-aggregate-member-inspection-budget/.openspec.yaml create mode 100644 openspec/changes/document-aggregate-member-inspection-budget/design.md create mode 100644 openspec/changes/document-aggregate-member-inspection-budget/proposal.md create mode 100644 openspec/changes/document-aggregate-member-inspection-budget/specs/exception-event-metadata/spec.md create mode 100644 openspec/changes/document-aggregate-member-inspection-budget/tasks.md diff --git a/openspec/changes/document-aggregate-member-inspection-budget/.openspec.yaml b/openspec/changes/document-aggregate-member-inspection-budget/.openspec.yaml new file mode 100644 index 00000000..a40cb63c --- /dev/null +++ b/openspec/changes/document-aggregate-member-inspection-budget/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-14 diff --git a/openspec/changes/document-aggregate-member-inspection-budget/design.md b/openspec/changes/document-aggregate-member-inspection-budget/design.md new file mode 100644 index 00000000..84dbbb2f --- /dev/null +++ b/openspec/changes/document-aggregate-member-inspection-budget/design.md @@ -0,0 +1,28 @@ +## Context + +PostHog/posthog-js#4941 uses independent counters for emitted exceptions and aggregate member inspections. Duplicate and cyclic references do not consume output slots, so a 50-entry output limit alone can still scan arbitrarily large collections. + +## Goals / Non-Goals + +**Goals:** Agree on the existing 1,000-inspection limit and its observable truncation behavior for SDK ports. + +**Non-Goals:** Change the wire format, add telemetry fields, change cause traversal, or alter the implementation in posthog-js. + +## Decisions + +Use one capture-wide counter shared by nested aggregate collections. Increment it before reading each member, including reads that throw and members skipped as duplicates or cycles. Do not reset it when descending into another aggregate. Stop inspecting members after 1,000 attempts, while allowing the already-inspected member and its cause chain to finish within the 50-entry limit. + +A per-aggregate budget would multiply work in nested graphs. An output-only budget does not bound duplicate scans. A time-based budget would produce nondeterministic output across SDKs. The fixed limit matches the JavaScript reference implementation and its regression tests. + +## Risks / Trade-offs + +- Valid members after a long duplicate prefix can be omitted even when fewer than 50 entries are emitted. Explicit scenarios document this deliberate work bound. +- Ports could count only emitted children or reset the counter per group. Shared-budget and duplicate-prefix scenarios distinguish these implementations. + +## Migration Plan + +Request approval of this proposal, then archive it on the same branch to sync the canonical spec. SDK ports can use the JavaScript tests as reference cases. No SDK deployment is part of this documentation PR. + +## Open Questions + +Reviewers must confirm the 1,000-inspection value as the shared target before this proposal is archived. diff --git a/openspec/changes/document-aggregate-member-inspection-budget/proposal.md b/openspec/changes/document-aggregate-member-inspection-budget/proposal.md new file mode 100644 index 00000000..1ff9ebae --- /dev/null +++ b/openspec/changes/document-aggregate-member-inspection-budget/proposal.md @@ -0,0 +1,23 @@ +## Why + +The exception metadata spec limits output to 50 entries but does not bound inspections of duplicate or cyclic aggregate members. PostHog/posthog-js#4941 adds a separate 1,000-member inspection budget, and reviewers requested a shared contract before other SDKs port it. + +## What Changes + +- Define a capture-wide limit of 1,000 aggregate member inspections, including skipped duplicates, cycles, and unreadable members. +- Clarify that this budget can stop member traversal before 50 exceptions are emitted. +- Preserve the existing traversal order, relationship metadata, and 50-entry output limit. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `exception-event-metadata`: Bound aggregate member inspection work independently of the serialized entry count. + +## Impact + +This proposal records the behavior implemented in https://github.com/PostHog/posthog-js/pull/4941 for agreement across SDK ports. It does not change SDK code, public APIs, or backend fields. Canonical specs will be synced by archiving on this branch after review approves the proposal. diff --git a/openspec/changes/document-aggregate-member-inspection-budget/specs/exception-event-metadata/spec.md b/openspec/changes/document-aggregate-member-inspection-budget/specs/exception-event-metadata/spec.md new file mode 100644 index 00000000..67faec86 --- /dev/null +++ b/openspec/changes/document-aggregate-member-inspection-budget/specs/exception-event-metadata/spec.md @@ -0,0 +1,61 @@ +## MODIFIED Requirements + +### Requirement: Nested exception relationship source + +For a nested exception, `mechanism.source` identifies the runtime relationship through which that entry was reached from its parent. The canonical relationships are `cause`, `context`, `unwrap`, `member`, `suppressed`, and `inner`. A first-party SDK SHALL use the canonical value when its runtime relationship has that meaning. It MAY preserve another stable, non-empty platform relationship only when none of the canonical values applies, and SHALL omit `source` when the relationship is unknown. + +`mechanism.source` SHALL NOT identify a framework or capture integration and SHALL NOT contain a source-file path. The outermost exception SHALL omit `mechanism.source`, because it has no parent in the event. A producer MUST NOT label every nested relationship `cause`: aggregate members, suppressed exceptions, implicit context, and multi-error unwrapping retain their distinct relationship. + +`$exception_list` is a flattened depth-first, parent-before-child representation of a conceptual exception tree. The outermost exception SHALL have `mechanism.exception_id` equal to integer `0`. Every subsequent entry SHALL have a unique non-negative integer `mechanism.exception_id`, an integer `mechanism.parent_id` referring to an earlier entry, `mechanism.type` equal to `chained`, and `mechanism.source` describing the parent edge. Siblings SHALL retain runtime order when the runtime defines one; otherwise the producer SHALL use a deterministic order based on the runtime's stable iteration representation. Linear chains follow the same representation. Producers SHALL detect cycles and SHALL serialize at most 50 entries. Subject to the aggregate member inspection budget below, truncation SHALL retain the outermost entry and the first 49 nested entries in canonical depth-first order. + +Producers SHALL inspect at most 1,000 aggregate members per captured event, using one budget shared across all nested aggregate collections. Each attempted member read SHALL consume one inspection before the member is read, including unreadable members and references skipped as duplicates or cycles. Cause traversal SHALL NOT consume member inspections. Once the budget is exhausted, producers SHALL stop reading aggregate members, including members of later or nested collections. They SHALL finish processing the already-inspected member and its cause chain within the 50-entry output limit. This inspection budget MAY truncate aggregate traversal before 50 entries are emitted. The output limit SHALL still stop traversal earlier when 50 entries have been retained. + +#### Scenario: Wrapped cause records its relationship (@both) +- **GIVEN** an outer exception wraps another exception as its cause +- **WHEN** the SDK serializes both exceptions +- **THEN** the outer exception should omit `mechanism.source` +- **AND** the nested exception should have `mechanism.type` equal to `chained` +- **AND** the nested exception should have `mechanism.source` equal to `cause` + +#### Scenario: Aggregate member is not mislabeled as a cause (@both) +- **GIVEN** an aggregate exception contains two member exceptions in runtime order +- **WHEN** the SDK serializes the aggregate and members +- **THEN** the aggregate should have `mechanism.exception_id` equal to `0` +- **AND** both members should have `mechanism.parent_id` equal to `0` +- **AND** both members should have `mechanism.type` equal to `chained` +- **AND** both members should have `mechanism.source` equal to `member` +- **AND** the members should retain runtime order + +#### Scenario: Nested cause beneath an aggregate member retains its parent (@both) +- **GIVEN** the second member of an aggregate wraps a cause +- **WHEN** the SDK serializes the exception tree +- **THEN** the cause's `mechanism.parent_id` should equal the second member's `mechanism.exception_id` +- **AND** the cause should have `mechanism.type` equal to `chained` +- **AND** the cause should have `mechanism.source` equal to `cause` + +#### Scenario: Cycles and oversized trees are bounded deterministically (@both) +- **GIVEN** an exception graph contains a cycle or more than 50 reachable entries +- **WHEN** the SDK serializes the exception tree +- **THEN** no exception object should be serialized more than once +- **AND** `$exception_list` should contain at most 50 entries +- **AND** retained entries should be the earliest entries in canonical depth-first order reached within the aggregate member inspection budget + +#### Scenario: Duplicate members consume the inspection budget (@both) +- **GIVEN** an aggregate contains 1,000 references to itself followed by a distinct exception +- **WHEN** the SDK serializes the aggregate +- **THEN** only the outermost aggregate should be emitted +- **AND** the SDK should not read the 1,001st member + +#### Scenario: Nested aggregates share the inspection budget (@both) +- **GIVEN** an outer aggregate contains an inner aggregate followed by a distinct sibling +- **AND** the inner aggregate contains 999 references to itself followed by another distinct exception +- **WHEN** the SDK serializes the aggregate tree +- **THEN** only the outer and inner aggregates should be emitted +- **AND** the first outer member and the 999 inner members should exhaust the shared budget +- **AND** neither later distinct exception should be read + +#### Scenario: Last inspected member retains its cause (@both) +- **GIVEN** an aggregate contains 999 references to itself followed by a distinct error with a cause +- **WHEN** the SDK serializes the aggregate +- **THEN** the aggregate, distinct error, and its cause should be emitted in that order +- **AND** the cause should retain its parent relationship without consuming another member inspection diff --git a/openspec/changes/document-aggregate-member-inspection-budget/tasks.md b/openspec/changes/document-aggregate-member-inspection-budget/tasks.md new file mode 100644 index 00000000..ee9172a6 --- /dev/null +++ b/openspec/changes/document-aggregate-member-inspection-budget/tasks.md @@ -0,0 +1,9 @@ +## 1. Validate and agree on the proposal + +- [x] 1.1 Validate the change and canonical specs with OpenSpec strict validation. +- [ ] 1.2 Obtain reviewer agreement on the capture-wide 1,000-member inspection budget. + +## 2. Publish the canonical contract + +- [ ] 2.1 After approval, archive this change on the same branch to sync the delta into the canonical spec. +- [ ] 2.2 Run strict spec validation after archiving and link the final contract from PostHog/posthog-js#4941. From 1c9008e1ddcde0cc6cbd65181c2059de7d1399a3 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 15 Sep 2026 08:17:37 +0200 Subject: [PATCH 2/2] docs: finalize and archive aggregate inspection budget --- .../.openspec.yaml | 0 .../design.md | 4 +-- .../proposal.md | 2 +- .../specs/exception-event-metadata/spec.md | 0 .../tasks.md | 6 ++-- .../specs/exception-event-metadata/spec.md | 28 ++++++++++++++++--- 6 files changed, 30 insertions(+), 10 deletions(-) rename openspec/changes/{document-aggregate-member-inspection-budget => archive/2026-09-15-document-aggregate-member-inspection-budget}/.openspec.yaml (100%) rename openspec/changes/{document-aggregate-member-inspection-budget => archive/2026-09-15-document-aggregate-member-inspection-budget}/design.md (83%) rename openspec/changes/{document-aggregate-member-inspection-budget => archive/2026-09-15-document-aggregate-member-inspection-budget}/proposal.md (73%) rename openspec/changes/{document-aggregate-member-inspection-budget => archive/2026-09-15-document-aggregate-member-inspection-budget}/specs/exception-event-metadata/spec.md (100%) rename openspec/changes/{document-aggregate-member-inspection-budget => archive/2026-09-15-document-aggregate-member-inspection-budget}/tasks.md (59%) diff --git a/openspec/changes/document-aggregate-member-inspection-budget/.openspec.yaml b/openspec/changes/archive/2026-09-15-document-aggregate-member-inspection-budget/.openspec.yaml similarity index 100% rename from openspec/changes/document-aggregate-member-inspection-budget/.openspec.yaml rename to openspec/changes/archive/2026-09-15-document-aggregate-member-inspection-budget/.openspec.yaml diff --git a/openspec/changes/document-aggregate-member-inspection-budget/design.md b/openspec/changes/archive/2026-09-15-document-aggregate-member-inspection-budget/design.md similarity index 83% rename from openspec/changes/document-aggregate-member-inspection-budget/design.md rename to openspec/changes/archive/2026-09-15-document-aggregate-member-inspection-budget/design.md index 84dbbb2f..d43a595c 100644 --- a/openspec/changes/document-aggregate-member-inspection-budget/design.md +++ b/openspec/changes/archive/2026-09-15-document-aggregate-member-inspection-budget/design.md @@ -21,8 +21,8 @@ A per-aggregate budget would multiply work in nested graphs. An output-only budg ## Migration Plan -Request approval of this proposal, then archive it on the same branch to sync the canonical spec. SDK ports can use the JavaScript tests as reference cases. No SDK deployment is part of this documentation PR. +Archive this change on the same branch to sync the canonical spec. SDK ports can use the JavaScript tests as reference cases. No SDK deployment is part of this documentation PR. ## Open Questions -Reviewers must confirm the 1,000-inspection value as the shared target before this proposal is archived. +None. The maintainer approved finalizing the 1,000-inspection budget as the shared target. diff --git a/openspec/changes/document-aggregate-member-inspection-budget/proposal.md b/openspec/changes/archive/2026-09-15-document-aggregate-member-inspection-budget/proposal.md similarity index 73% rename from openspec/changes/document-aggregate-member-inspection-budget/proposal.md rename to openspec/changes/archive/2026-09-15-document-aggregate-member-inspection-budget/proposal.md index 1ff9ebae..d4fdc382 100644 --- a/openspec/changes/document-aggregate-member-inspection-budget/proposal.md +++ b/openspec/changes/archive/2026-09-15-document-aggregate-member-inspection-budget/proposal.md @@ -20,4 +20,4 @@ None. ## Impact -This proposal records the behavior implemented in https://github.com/PostHog/posthog-js/pull/4941 for agreement across SDK ports. It does not change SDK code, public APIs, or backend fields. Canonical specs will be synced by archiving on this branch after review approves the proposal. +This change records the behavior implemented in https://github.com/PostHog/posthog-js/pull/4941 as the shared target for SDK ports. It does not change SDK code, public APIs, or backend fields. Archiving syncs the requirement into the canonical spec on this branch. diff --git a/openspec/changes/document-aggregate-member-inspection-budget/specs/exception-event-metadata/spec.md b/openspec/changes/archive/2026-09-15-document-aggregate-member-inspection-budget/specs/exception-event-metadata/spec.md similarity index 100% rename from openspec/changes/document-aggregate-member-inspection-budget/specs/exception-event-metadata/spec.md rename to openspec/changes/archive/2026-09-15-document-aggregate-member-inspection-budget/specs/exception-event-metadata/spec.md diff --git a/openspec/changes/document-aggregate-member-inspection-budget/tasks.md b/openspec/changes/archive/2026-09-15-document-aggregate-member-inspection-budget/tasks.md similarity index 59% rename from openspec/changes/document-aggregate-member-inspection-budget/tasks.md rename to openspec/changes/archive/2026-09-15-document-aggregate-member-inspection-budget/tasks.md index ee9172a6..22227874 100644 --- a/openspec/changes/document-aggregate-member-inspection-budget/tasks.md +++ b/openspec/changes/archive/2026-09-15-document-aggregate-member-inspection-budget/tasks.md @@ -1,9 +1,9 @@ ## 1. Validate and agree on the proposal - [x] 1.1 Validate the change and canonical specs with OpenSpec strict validation. -- [ ] 1.2 Obtain reviewer agreement on the capture-wide 1,000-member inspection budget. +- [x] 1.2 Obtain reviewer agreement on the capture-wide 1,000-member inspection budget. ## 2. Publish the canonical contract -- [ ] 2.1 After approval, archive this change on the same branch to sync the delta into the canonical spec. -- [ ] 2.2 Run strict spec validation after archiving and link the final contract from PostHog/posthog-js#4941. +- [x] 2.1 After approval, archive this change on the same branch to sync the delta into the canonical spec. +- [x] 2.2 Run strict spec validation after archiving and link the final contract from PostHog/posthog-js#4941. diff --git a/openspec/specs/exception-event-metadata/spec.md b/openspec/specs/exception-event-metadata/spec.md index c424626f..112711b6 100644 --- a/openspec/specs/exception-event-metadata/spec.md +++ b/openspec/specs/exception-event-metadata/spec.md @@ -18,9 +18,7 @@ The client-only public `capture-exception` capability remains responsible for it - The **outermost exception** is `$exception_list[0]`, the exception observed by that capture boundary. - A **nested exception** is a cause, context, aggregate member, suppressed exception, inner exception, or other exception reached from another entry. - **Producer metadata** is serialized by the SDK before ingestion. **Processor metadata** is derived by Cymbal after ingestion. - ## Requirements - ### Requirement: Canonical exception event envelope An SDK-generated exception event SHALL use event name `$exception` and SHALL contain a non-empty `$exception_list`. Each list entry SHALL contain a non-empty string `type` and a string `value`; `value` MAY be empty when the runtime provides no message. An entry MAY include `module`, `thread_id`, `stacktrace`, and `mechanism` when known. @@ -204,7 +202,9 @@ For a nested exception, `mechanism.source` identifies the runtime relationship t `mechanism.source` SHALL NOT identify a framework or capture integration and SHALL NOT contain a source-file path. The outermost exception SHALL omit `mechanism.source`, because it has no parent in the event. A producer MUST NOT label every nested relationship `cause`: aggregate members, suppressed exceptions, implicit context, and multi-error unwrapping retain their distinct relationship. -`$exception_list` is a flattened depth-first, parent-before-child representation of a conceptual exception tree. The outermost exception SHALL have `mechanism.exception_id` equal to integer `0`. Every subsequent entry SHALL have a unique non-negative integer `mechanism.exception_id`, an integer `mechanism.parent_id` referring to an earlier entry, `mechanism.type` equal to `chained`, and `mechanism.source` describing the parent edge. Siblings SHALL retain runtime order when the runtime defines one; otherwise the producer SHALL use a deterministic order based on the runtime's stable iteration representation. Linear chains follow the same representation. Producers SHALL detect cycles and SHALL serialize at most 50 entries. Truncation SHALL retain the outermost entry and the first 49 nested entries in canonical depth-first order. +`$exception_list` is a flattened depth-first, parent-before-child representation of a conceptual exception tree. The outermost exception SHALL have `mechanism.exception_id` equal to integer `0`. Every subsequent entry SHALL have a unique non-negative integer `mechanism.exception_id`, an integer `mechanism.parent_id` referring to an earlier entry, `mechanism.type` equal to `chained`, and `mechanism.source` describing the parent edge. Siblings SHALL retain runtime order when the runtime defines one; otherwise the producer SHALL use a deterministic order based on the runtime's stable iteration representation. Linear chains follow the same representation. Producers SHALL detect cycles and SHALL serialize at most 50 entries. Subject to the aggregate member inspection budget below, truncation SHALL retain the outermost entry and the first 49 nested entries in canonical depth-first order. + +Producers SHALL inspect at most 1,000 aggregate members per captured event, using one budget shared across all nested aggregate collections. Each attempted member read SHALL consume one inspection before the member is read, including unreadable members and references skipped as duplicates or cycles. Cause traversal SHALL NOT consume member inspections. Once the budget is exhausted, producers SHALL stop reading aggregate members, including members of later or nested collections. They SHALL finish processing the already-inspected member and its cause chain within the 50-entry output limit. This inspection budget MAY truncate aggregate traversal before 50 entries are emitted. The output limit SHALL still stop traversal earlier when 50 entries have been retained. #### Scenario: Wrapped cause records its relationship (@both) - **GIVEN** an outer exception wraps another exception as its cause @@ -234,7 +234,27 @@ For a nested exception, `mechanism.source` identifies the runtime relationship t - **WHEN** the SDK serializes the exception tree - **THEN** no exception object should be serialized more than once - **AND** `$exception_list` should contain at most 50 entries -- **AND** retained entries should be the earliest entries in canonical depth-first order +- **AND** retained entries should be the earliest entries in canonical depth-first order reached within the aggregate member inspection budget + +#### Scenario: Duplicate members consume the inspection budget (@both) +- **GIVEN** an aggregate contains 1,000 references to itself followed by a distinct exception +- **WHEN** the SDK serializes the aggregate +- **THEN** only the outermost aggregate should be emitted +- **AND** the SDK should not read the 1,001st member + +#### Scenario: Nested aggregates share the inspection budget (@both) +- **GIVEN** an outer aggregate contains an inner aggregate followed by a distinct sibling +- **AND** the inner aggregate contains 999 references to itself followed by another distinct exception +- **WHEN** the SDK serializes the aggregate tree +- **THEN** only the outer and inner aggregates should be emitted +- **AND** the first outer member and the 999 inner members should exhaust the shared budget +- **AND** neither later distinct exception should be read + +#### Scenario: Last inspected member retains its cause (@both) +- **GIVEN** an aggregate contains 999 references to itself followed by a distinct error with a cause +- **WHEN** the SDK serializes the aggregate +- **THEN** the aggregate, distinct error, and its cause should be emitted in that order +- **AND** the cause should retain its parent relationship without consuming another member inspection ### Requirement: Canonical exception level