Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-14
Original file line number Diff line number Diff line change
@@ -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

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

None. The maintainer approved finalizing the 1,000-inspection budget as the shared target.
Original file line number Diff line number Diff line change
@@ -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 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.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## 1. Validate and agree on the proposal

- [x] 1.1 Validate the change and canonical specs with OpenSpec strict validation.
- [x] 1.2 Obtain reviewer agreement on the capture-wide 1,000-member inspection budget.

## 2. Publish the canonical contract

- [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.
28 changes: 24 additions & 4 deletions openspec/specs/exception-event-metadata/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading