@volter/twin-segment 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +154 -0
- package/client/segment-mirror.css +45 -0
- package/client/segment-mirror.tsx +154 -0
- package/dist/client/segment-mirror.bundle.js +342 -0
- package/dist/client/segment-mirror.css +45 -0
- package/dist/client/segment-mirror.d.ts +34 -0
- package/dist/client/segment-mirror.js +80 -0
- package/dist/client/segment-mirror.tsx +154 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +34 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +53 -0
- package/dist/src/segment-budget.d.ts +41 -0
- package/dist/src/segment-budget.js +112 -0
- package/dist/src/segment-capabilities.d.ts +12 -0
- package/dist/src/segment-capabilities.gen.d.ts +3 -0
- package/dist/src/segment-capabilities.gen.js +22 -0
- package/dist/src/segment-capabilities.js +907 -0
- package/dist/src/segment-conformance.d.ts +8 -0
- package/dist/src/segment-conformance.js +106 -0
- package/dist/src/segment-connector.d.ts +76 -0
- package/dist/src/segment-connector.js +226 -0
- package/dist/src/segment-mirror-ui.d.ts +42 -0
- package/dist/src/segment-mirror-ui.js +143 -0
- package/dist/src/segment-server.d.ts +25 -0
- package/dist/src/segment-server.js +90 -0
- package/dist/src/segment-surface.gen.d.ts +48 -0
- package/dist/src/segment-surface.gen.js +267 -0
- package/dist/src/segment-twin.d.ts +98 -0
- package/dist/src/segment-twin.js +543 -0
- package/package.json +59 -0
- package/src/cli.ts +30 -0
- package/src/index.ts +87 -0
- package/src/segment-budget.ts +138 -0
- package/src/segment-capabilities.gen.ts +25 -0
- package/src/segment-capabilities.ts +988 -0
- package/src/segment-conformance.ts +123 -0
- package/src/segment-connector.ts +233 -0
- package/src/segment-journey.uitest.ts +116 -0
- package/src/segment-mirror-ui.ts +157 -0
- package/src/segment-server.ts +95 -0
- package/src/segment-surface.gen.ts +277 -0
- package/src/segment-twin.ts +664 -0
package/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# @volter/twin-segment
|
|
2
|
+
|
|
3
|
+
> **Legacy connector helpers:** this package still has callable helpers using the retired v1
|
|
4
|
+
> `syncPull` API. Those paths require migration before use on the current kernel;
|
|
5
|
+
> older helper descriptions below do not establish current compatibility. Check the
|
|
6
|
+
> [generated index](../../../generated/INDEX.md) for protocol standing and use the
|
|
7
|
+
> [shared model](../../../docs/concepts/the-model.md) for current state semantics.
|
|
8
|
+
|
|
9
|
+
A local, stateful replica of **Segment's HTTP Tracking API**. Point an unmodified
|
|
10
|
+
`@segment/analytics-node` client at it with nothing but the SDK's own `host` setting and your
|
|
11
|
+
events fold into durable twin state — offline, deterministic, with no write key that means
|
|
12
|
+
anything and no traffic leaving the machine.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bunx world-segment serve --port 8787 # then: new Analytics({ writeKey, host: 'http://localhost:8787' })
|
|
16
|
+
bunx world-segment ops # the ratified surface, one line per operation
|
|
17
|
+
bunx world-segment conformance # vendor-property checks over the modeled plane
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Coverage
|
|
21
|
+
|
|
22
|
+
**The denominator is AUTHORED, not compiled.** Segment publishes no machine-readable spec for its
|
|
23
|
+
ingestion surface, so this pack's sixteen operations were ratified one at a time from evidence at
|
|
24
|
+
`var/line/segment/SURFACE.json`, each entry carrying the source it stands on:
|
|
25
|
+
|
|
26
|
+
| tier | ops | grounding |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| SDK wire literal | 2 | `POST /v1/batch` (the single URL `@segment/analytics-node@2.3.0`'s Publisher ever posts to) and `POST /token` on the OAuth authorization server |
|
|
29
|
+
| Docs only, not verified offline | 14 | the six direct `POST /v1/{track,identify,page,screen,group,alias}` routes, the mobile batch route `POST /v1/b`, analytics.js's `POST /v1/t`, and the six literal Pixel Routes `GET /v1/pixel/{...}` |
|
|
30
|
+
|
|
31
|
+
Docs grounding comes from **`github.com/segmentio/segment-docs`, the vendor's own documentation
|
|
32
|
+
source repository** — `segment.com` answers 403 to non-browser clients, so the rendered pages were
|
|
33
|
+
not read. Rulings and the ratified vendor facts live in `var/line/segment/RULINGS.json` and
|
|
34
|
+
`FACTS.json`.
|
|
35
|
+
|
|
36
|
+
Coverage reads honestly **partial**: the ingestion half of the tracking plane is modeled, and
|
|
37
|
+
everything else is a filed `todo`. That is the intended shape — a broad honest denominator beats a
|
|
38
|
+
thin self-portrait.
|
|
39
|
+
|
|
40
|
+
### What is modeled
|
|
41
|
+
|
|
42
|
+
- **`POST /v1/batch`** — the envelope every official server SDK sends, `{batch, writeKey, sentAt}`,
|
|
43
|
+
dispatched **per message** by each message's own `type`. Batch-level `context` and
|
|
44
|
+
`integrations` merge into every message, message-level keys winning, as the vendor documents.
|
|
45
|
+
- **The direct routes** `POST /v1/{track,identify,page,group,alias}`, which fold through the
|
|
46
|
+
*same* code path as their batched equivalents, so the two can never disagree.
|
|
47
|
+
- **All three documented auth schemes**: `writeKey` in the body with no header (the SDK's own
|
|
48
|
+
default), HTTP Basic with the write key as the username and an empty password (analytics-node
|
|
49
|
+
v1's shape), and OAuth `Bearer` alongside a payload write key.
|
|
50
|
+
- **The vendor's accept-and-drop semantics.** Segment "returns a `200` response for all API
|
|
51
|
+
requests except errors caused by large payloads and JSON errors", and then silently rejects
|
|
52
|
+
events with no `userId`/`anonymousId` (its own `no_user_anon_id` error), Tracks with no `event`
|
|
53
|
+
name, and batch members past 2,500 events or 32KB. The twin reproduces that exactly and records
|
|
54
|
+
each drop in a local `dropped` projection so the outcome stays inspectable.
|
|
55
|
+
- **`messageId` dedupe**, the vendor's own idempotency key — across requests and within one batch.
|
|
56
|
+
- The documented **400s** (invalid JSON, oversize payload) with the `{code, message}` envelope
|
|
57
|
+
`analytics-python` parses off every non-200.
|
|
58
|
+
|
|
59
|
+
### Deliberate deviation: unmodeled operations fail LOUDLY
|
|
60
|
+
|
|
61
|
+
The real vendor answers `200` to almost everything. This twin answers a `404` `[twin gap]` naming
|
|
62
|
+
the operation for anything it has ratified but not modeled — `/v1/b`, `/v1/t`, `/v1/screen`, the
|
|
63
|
+
six pixel routes, `/token`, and the `screen` message type inside an otherwise-modeled batch. That
|
|
64
|
+
is a knowing departure from fidelity, and the right one: a `200` that stores nothing is
|
|
65
|
+
indistinguishable from success, which is the one thing a twin may never do. It is ruled in
|
|
66
|
+
`var/line/segment/RULINGS.json` and pinned by `segment.api.fail_loudly`.
|
|
67
|
+
|
|
68
|
+
### What this denominator excludes, and why
|
|
69
|
+
|
|
70
|
+
Each of these is a **ruling**, not an omission:
|
|
71
|
+
|
|
72
|
+
- **The Public API** (`api.segmentapis.com`) — workspaces, sources, destinations, warehouses,
|
|
73
|
+
tracking plans. A different product on a different host behind a workspace token. Its host is
|
|
74
|
+
**not** claimed in the injector map, so a Public API call from a world still reaches the real
|
|
75
|
+
vendor: a named exposure, and the first thing a follow-up article should close.
|
|
76
|
+
- **The Profile API** (`profiles.segment.com`) — a different host, credential and product tier,
|
|
77
|
+
returning resolved profiles rather than ingested events.
|
|
78
|
+
- **`cdn.segment.com/v1/projects/{writeKey}/settings`** — real and first-party, but that host also
|
|
79
|
+
serves the analytics.js bundle, so intercepting it would break loading the real library.
|
|
80
|
+
- **Inbound webhooks** — this vendor has none on the tracking plane. Segment's "Webhooks (Actions)"
|
|
81
|
+
is a *destination* (Segment → you).
|
|
82
|
+
- **The Objects APIs** (`objects.segment.com/v1/set` and the four `/v0` routes of
|
|
83
|
+
`objects-bulk-api.segmentapis.com`) — real, first-party, on the same server-source catalog
|
|
84
|
+
branch and behind the same source write key, but beta, warehouse-object loading rather than
|
|
85
|
+
event ingestion, and unreachable by any `analytics-<language>` client, so this article's
|
|
86
|
+
SDK-anchored method has nothing to verify against. Neither host is claimed in the injector map —
|
|
87
|
+
the same named exposure the Public API carries, and the same follow-up
|
|
88
|
+
(`denominator:object-apis-plane-named-but-not-claimed`, filed by A3).
|
|
89
|
+
|
|
90
|
+
### Connector
|
|
91
|
+
|
|
92
|
+
**Push** replays locally-ingested flushes onto the real `POST /v1/batch` through an injected
|
|
93
|
+
client, rebuilding the SDK's own envelope. **Pull mirrors nothing, and that is a vendor fact**:
|
|
94
|
+
every one of the sixteen ratified operations is a *write*. Segment's own answer to "did my event
|
|
95
|
+
land" is the browser Source Debugger, a live websocket view — not an API. Rather than fake an
|
|
96
|
+
empty account over real observed state, the gap is filed as `segment.connector.pull`.
|
|
97
|
+
|
|
98
|
+
### UI mirror — the Source Debugger
|
|
99
|
+
|
|
100
|
+
Segment **is owed a mirror**. Its core browser job — wiring sources to destinations, watching the
|
|
101
|
+
Source Debugger, editing tracking plans — is exactly the "the UI is the product" case, so
|
|
102
|
+
`ui-scope.json` rules `needsUi: true`.
|
|
103
|
+
|
|
104
|
+
The **Source Debugger** is built (`segment.ui.debugger`): a React screen showing the two live
|
|
105
|
+
streams a source has, the **accepted** messages with their type, label, subject and properties, and
|
|
106
|
+
the **dropped** ones with the REASON they were refused. It cannot BE an API capability: all sixteen
|
|
107
|
+
ratified operations are ingests and none of them reads a message back, so the vendor's own answer to
|
|
108
|
+
"my event returned 200 and never arrived" is a screen rather than a call.
|
|
109
|
+
|
|
110
|
+
**The dropped pane is the twin's, not the vendor's** — a deliberate superset, labelled as one on the
|
|
111
|
+
screen itself. Segment's real Source Debugger shows what a source RECEIVED; it has no
|
|
112
|
+
accepted-then-silently-dropped view, because the vendor answers 200 and discards. This twin records
|
|
113
|
+
the refusal instead, which is the only way that failure becomes observable at all. Of the seven drop
|
|
114
|
+
reasons it shows, only `no_user_anon_id` is a string Segment itself prints (its Errors page); the
|
|
115
|
+
other six are this pack's own descriptive labels and the row marks each of them **twin label**.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
bun run packages/twin/segment/src/cli.ts mirror --port 4100 # the debugger + the tracking API, one origin
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The screen reads the twin's own **store door** (`GET /twin/store/events`, `…/dropped`) on the same
|
|
122
|
+
origin the tracking routes are served on — one serving code path, so the screen and its data cannot
|
|
123
|
+
drift. A real headless-Chromium journey (`segment-journey.uitest.ts`,
|
|
124
|
+
`segment.journey.debugger`) drives it end to end.
|
|
125
|
+
|
|
126
|
+
Two screens remain filed as live todos rather than fabricated: `segment.ui.connections` (the
|
|
127
|
+
Connections graph, which needs the Public API plane this pack does not twin) and
|
|
128
|
+
`segment.ui.profiles` (a user/group explorer over the identity projections). Cite those two as a
|
|
129
|
+
**tracked gap**, never as precedent for omitting a mirror.
|
|
130
|
+
|
|
131
|
+
### The store doors
|
|
132
|
+
|
|
133
|
+
The HTTP Tracking API is write-only: every route is an ingest and none of them reads a message
|
|
134
|
+
back. What was accepted — and what was accepted-and-dropped, which Segment answers `200` to and
|
|
135
|
+
never reports — is read through the twin's own named projections, `GET /twin/store/{events,
|
|
136
|
+
identities,groups,dropped}`: the read-only doors the programming model gives a twin whose vendor
|
|
137
|
+
has no listing endpoint (the `mailgun` precedent). They are deliberately **out of the capability
|
|
138
|
+
manifest**: counting scaffolding the vendor does not have would pad the denominator. `GET /twin`
|
|
139
|
+
names them under `stores`.
|
|
140
|
+
|
|
141
|
+
### Rate budget
|
|
142
|
+
|
|
143
|
+
The vendor publishes `1,000 requests per second` per workspace, and this pack deliberately does
|
|
144
|
+
**not** adopt it: that is a workspace-wide production-throughput recommendation, not a
|
|
145
|
+
per-connector allowance, and this pack's only live caller is a replay path. The declaration sits
|
|
146
|
+
at exactly the kernel fallback's shape (60 weighted units / 60s at `defaultWeight` 2). See
|
|
147
|
+
`src/segment-budget.ts` for the full reasoning, the figures, their source and the access date.
|
|
148
|
+
|
|
149
|
+
### Fidelity test
|
|
150
|
+
|
|
151
|
+
`src/segment-sdk.integration.test.ts` drives the **real, unmodified** `@segment/analytics-node`
|
|
152
|
+
(pinned `^2.3.0`) against an in-process twin server, configured only through the SDK's own public
|
|
153
|
+
`host`/`flushAt`/`maxRetries` settings — configuration, not modification. It asserts the SDK's own
|
|
154
|
+
**delivery verdict**, so a twin answering the wrong status cannot pass.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #0f1115;
|
|
3
|
+
--panel: #171a21;
|
|
4
|
+
--line: #262b35;
|
|
5
|
+
--text: #e6e9ef;
|
|
6
|
+
--muted: #9aa4b2;
|
|
7
|
+
--accent: #292929;
|
|
8
|
+
--track: #52bd94;
|
|
9
|
+
--identity: #6aa9f0;
|
|
10
|
+
--screenish: #b98cf0;
|
|
11
|
+
--dropped: #d24b4b;
|
|
12
|
+
}
|
|
13
|
+
* { box-sizing: border-box; }
|
|
14
|
+
body { margin: 0; font-family: -apple-system, system-ui, sans-serif; background: var(--bg); color: var(--text); }
|
|
15
|
+
.app { max-width: 1040px; margin: 0 auto; padding: 0 20px 32px; }
|
|
16
|
+
.app-header { display: flex; align-items: center; justify-content: space-between; height: 60px; border-bottom: 1px solid var(--line); }
|
|
17
|
+
.app-title { font-size: 18px; font-weight: 700; margin: 0; letter-spacing: 0.02em; }
|
|
18
|
+
.counts { display: flex; gap: 14px; font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
19
|
+
.count-dropped { color: var(--dropped); }
|
|
20
|
+
.app-nav { display: flex; gap: 6px; padding: 14px 0; }
|
|
21
|
+
.nav-item { background: none; border: 1px solid transparent; color: var(--muted); padding: 7px 14px; border-radius: 999px; cursor: pointer; font-size: 14px; }
|
|
22
|
+
.nav-item:hover { color: var(--text); }
|
|
23
|
+
.nav-item-active { background: var(--accent); border-color: var(--line); color: var(--text); }
|
|
24
|
+
.event-stream, .dropped-stream { list-style: none; margin: 0; padding: 0; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
|
25
|
+
.event-row, .dropped-row { padding: 12px 14px; border-bottom: 1px solid var(--line); }
|
|
26
|
+
.event-row:last-child, .dropped-row:last-child { border-bottom: none; }
|
|
27
|
+
.event-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
28
|
+
.event-clock { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
29
|
+
.event-label { font-weight: 600; font-size: 14px; }
|
|
30
|
+
.event-subject, .drop-endpoint { color: var(--muted); font-size: 12px; margin-left: auto; }
|
|
31
|
+
.drop-reason { color: var(--dropped); font-size: 13px; }
|
|
32
|
+
.drop-reason-twin { font-size: 11px; color: var(--muted); border: 1px dashed var(--line); border-radius: 4px; padding: 1px 6px; }
|
|
33
|
+
.stream-note { color: var(--muted); font-size: 12px; margin: 0 0 10px; }
|
|
34
|
+
.stream-note code { color: var(--text); }
|
|
35
|
+
.pill { padding: 3px 10px; border-radius: 999px; font-size: 12px; white-space: nowrap; }
|
|
36
|
+
.pill-track { background: rgba(82,189,148,0.18); color: var(--track); }
|
|
37
|
+
.pill-identity { background: rgba(106,169,240,0.18); color: var(--identity); }
|
|
38
|
+
.pill-screenish { background: rgba(185,140,240,0.18); color: var(--screenish); }
|
|
39
|
+
.pill-dropped { background: rgba(210,75,75,0.18); color: var(--dropped); }
|
|
40
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
|
41
|
+
.chips-empty { color: var(--muted); font-size: 12px; }
|
|
42
|
+
.chip { display: inline-flex; gap: 6px; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 3px 8px; font-size: 12px; }
|
|
43
|
+
.chip-key { color: var(--muted); }
|
|
44
|
+
.chip-value { font-variant-numeric: tabular-nums; }
|
|
45
|
+
.empty { color: var(--muted); padding: 24px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; }
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Segment mirror — the SOURCE DEBUGGER, as a React/TSX client. Every byte on screen is fetched
|
|
2
|
+
// from the twin's OWN store door on the same origin (`/twin/store/events`, `/twin/store/dropped`)
|
|
3
|
+
// — the kernel's read-only named-projection door, and the sanctioned way a mirror reads twin
|
|
4
|
+
// state. API↔UI parity: this is the projection the ingest routes fold into, not a second copy.
|
|
5
|
+
import { createRoot } from 'react-dom/client';
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
import {
|
|
8
|
+
PILL_CLASS,
|
|
9
|
+
clockLabel,
|
|
10
|
+
messageLabel,
|
|
11
|
+
propertyChips,
|
|
12
|
+
subjectOf,
|
|
13
|
+
typeTone,
|
|
14
|
+
type SegmentRow,
|
|
15
|
+
} from '../src/segment-mirror-ui.ts';
|
|
16
|
+
|
|
17
|
+
// WHERE THIS MIRROR LIVES. Served at a vendor root the base is '' (fetches are root-relative, as
|
|
18
|
+
// before); served under a path prefix with a <base> tag every read and write resolves inside that
|
|
19
|
+
// prefix instead of escaping it.
|
|
20
|
+
const WIRE_BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
|
|
21
|
+
|
|
22
|
+
const STREAMS = [
|
|
23
|
+
{ key: 'events', label: 'Accepted' },
|
|
24
|
+
{ key: 'dropped', label: 'Dropped' },
|
|
25
|
+
] as const;
|
|
26
|
+
type StreamKey = (typeof STREAMS)[number]['key'];
|
|
27
|
+
|
|
28
|
+
/** Read one named projection off the twin's store door. */
|
|
29
|
+
export async function store(name: string): Promise<SegmentRow[]> {
|
|
30
|
+
const res = await fetch(`${WIRE_BASE}/twin/store/${name}`);
|
|
31
|
+
if (!res.ok) return [];
|
|
32
|
+
const body = (await res.json()) as unknown;
|
|
33
|
+
return Array.isArray(body) ? (body as SegmentRow[]) : [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The property/trait chips on an expanded row — what a debugger reader is actually looking for
|
|
37
|
+
* when an event "arrived but is wrong". */
|
|
38
|
+
export function PropertyChips({ row }: { row: SegmentRow }) {
|
|
39
|
+
const chips = [...propertyChips(row.properties), ...propertyChips(row.traits)];
|
|
40
|
+
if (chips.length === 0) return <div className="chips chips-empty">no properties</div>;
|
|
41
|
+
return (
|
|
42
|
+
<div className="chips">
|
|
43
|
+
{chips.map((c) => (
|
|
44
|
+
<span className="chip" key={c.key}>
|
|
45
|
+
<span className="chip-key">{c.key}</span>
|
|
46
|
+
<span className="chip-value">{c.value}</span>
|
|
47
|
+
</span>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One row of the accepted stream. */
|
|
54
|
+
export function EventRow({ row }: { row: SegmentRow }) {
|
|
55
|
+
const tone = typeTone(row.messageType);
|
|
56
|
+
return (
|
|
57
|
+
<li className="event-row" data-message-id={String(row.messageId ?? '')}>
|
|
58
|
+
<div className="event-head">
|
|
59
|
+
<span className="event-clock">{clockLabel(row.receivedAt)}</span>
|
|
60
|
+
<span className={PILL_CLASS[tone]}>{String(row.messageType ?? '')}</span>
|
|
61
|
+
<span className="event-label">{messageLabel(row)}</span>
|
|
62
|
+
<span className="event-subject">{subjectOf(row)}</span>
|
|
63
|
+
</div>
|
|
64
|
+
<PropertyChips row={row} />
|
|
65
|
+
</li>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One row of the dropped stream.
|
|
71
|
+
*
|
|
72
|
+
* THIS PANE IS THE TWIN'S, NOT THE VENDOR'S, and the screen says so. Segment's real Source
|
|
73
|
+
* Debugger shows what a source RECEIVED; it has no "accepted-then-silently-dropped, with a reason"
|
|
74
|
+
* view, because the vendor answers 200 and discards. This twin records the refusal instead, which
|
|
75
|
+
* is the only way "returned 200 and never arrived" becomes observable — a superset of the vendor's
|
|
76
|
+
* screen, and the honest label for it. Only `no_user_anon_id` is a reason Segment itself prints
|
|
77
|
+
* (its Errors page); the other six are this pack's own descriptive strings, which is why the row
|
|
78
|
+
* marks them.
|
|
79
|
+
*/
|
|
80
|
+
export function DroppedRow({ row }: { row: SegmentRow }) {
|
|
81
|
+
return (
|
|
82
|
+
<li className="dropped-row" data-message-id={String(row.messageId ?? '')}>
|
|
83
|
+
<div className="event-head">
|
|
84
|
+
<span className={PILL_CLASS.dropped}>dropped</span>
|
|
85
|
+
<span className="event-label">{String(row.messageType ?? '')}</span>
|
|
86
|
+
<span className="drop-reason">{String(row.reason ?? 'unknown')}</span>
|
|
87
|
+
{String(row.reason ?? '') === 'no_user_anon_id'
|
|
88
|
+
? null
|
|
89
|
+
: <span className="drop-reason-twin" title="This reason is a twin-authored label. Segment prints only `no_user_anon_id`.">twin label</span>}
|
|
90
|
+
<span className="drop-endpoint">{String(row.endpoint ?? '')}</span>
|
|
91
|
+
</div>
|
|
92
|
+
</li>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The accepted stream, newest last (the order the source received them). */
|
|
97
|
+
export function EventStream({ rows }: { rows: SegmentRow[] }) {
|
|
98
|
+
if (rows.length === 0) return <div className="empty">No events yet. Send one and it appears here.</div>;
|
|
99
|
+
return <ul className="event-stream">{rows.map((r) => <EventRow row={r} key={String(r.messageId)} />)}</ul>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The dropped stream. */
|
|
103
|
+
export function DroppedStream({ rows }: { rows: SegmentRow[] }) {
|
|
104
|
+
if (rows.length === 0) return <div className="empty">Nothing dropped. Every message this source received was attributed.</div>;
|
|
105
|
+
return (
|
|
106
|
+
<>
|
|
107
|
+
<p className="stream-note">
|
|
108
|
+
Segment answers 200 and discards these; the twin records them. Only <code>no_user_anon_id</code> is
|
|
109
|
+
a reason Segment itself prints — the rest carry a <span className="drop-reason-twin">twin label</span>.
|
|
110
|
+
</p>
|
|
111
|
+
<ul className="dropped-stream">{rows.map((r) => <DroppedRow row={r} key={String(r.messageId)} />)}</ul>
|
|
112
|
+
</>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function App() {
|
|
117
|
+
const [stream, setStream] = useState<StreamKey>('events');
|
|
118
|
+
const [events, setEvents] = useState<SegmentRow[]>([]);
|
|
119
|
+
const [dropped, setDropped] = useState<SegmentRow[]>([]);
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
void (async () => {
|
|
123
|
+
setEvents(await store('events'));
|
|
124
|
+
setDropped(await store('dropped'));
|
|
125
|
+
})();
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<main className="app">
|
|
130
|
+
<header className="app-header">
|
|
131
|
+
<h1 className="app-title">Source Debugger</h1>
|
|
132
|
+
<span className="counts">
|
|
133
|
+
<span className="count-accepted" data-count={events.length}>{events.length} accepted</span>
|
|
134
|
+
<span className="count-dropped" data-count={dropped.length}>{dropped.length} dropped</span>
|
|
135
|
+
</span>
|
|
136
|
+
</header>
|
|
137
|
+
<nav className="app-nav">
|
|
138
|
+
{STREAMS.map((s) => (
|
|
139
|
+
<button
|
|
140
|
+
key={s.key}
|
|
141
|
+
className={stream === s.key ? 'nav-item nav-item-active' : 'nav-item'}
|
|
142
|
+
onClick={() => setStream(s.key)}
|
|
143
|
+
>
|
|
144
|
+
{s.label}
|
|
145
|
+
</button>
|
|
146
|
+
))}
|
|
147
|
+
</nav>
|
|
148
|
+
{stream === 'events' ? <EventStream rows={events} /> : <DroppedStream rows={dropped} />}
|
|
149
|
+
</main>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const mount = typeof document === 'undefined' ? null : document.getElementById('root');
|
|
154
|
+
if (mount) createRoot(mount).render(<App />);
|