effect-inspect 0.1.1 → 0.2.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.
Files changed (34) hide show
  1. package/README.md +68 -0
  2. package/app/dist/client/assets/{index-smV05cfr.js → index-T-bCzOWw.js} +2 -2
  3. package/app/dist/client/assets/routes-C2qO8k2W.js +5 -0
  4. package/app/dist/server/assets/{_tanstack-start-manifest_v-CFQ3DEVN.js → _tanstack-start-manifest_v-B2BMiICr.js} +3 -3
  5. package/app/dist/server/assets/{router-CN98Ramo.js → router-dMcw-pHq.js} +1 -1
  6. package/app/dist/server/assets/{routes-eZ4XqxE9.js → routes-BmnrEVoN.js} +203 -157
  7. package/app/dist/server/server.js +2 -2
  8. package/dist/cli/QueryCommands.d.ts +127 -0
  9. package/dist/cli/QueryCommands.js +768 -0
  10. package/dist/cli.d.ts +1 -2
  11. package/dist/cli.js +150 -8
  12. package/dist/client/Client.d.ts +42 -1
  13. package/dist/client/Client.js +88 -4
  14. package/dist/collector/QueryApi.d.ts +29 -0
  15. package/dist/collector/QueryApi.js +91 -0
  16. package/dist/collector/Server.d.ts +1 -1
  17. package/dist/collector/Server.js +15 -8
  18. package/dist/collector/Store.d.ts +56 -16
  19. package/dist/collector/Store.js +37 -11
  20. package/dist/protocol/Codec.d.ts +10 -0
  21. package/dist/protocol/Schema.d.ts +118 -1
  22. package/dist/protocol/Schema.js +53 -1
  23. package/dist/query/Client.d.ts +30 -0
  24. package/dist/query/Client.js +74 -0
  25. package/dist/query/Query.d.ts +599 -0
  26. package/dist/query/Query.js +876 -0
  27. package/dist/trace/Timing.d.ts +18 -0
  28. package/dist/trace/Timing.js +57 -0
  29. package/dist/trace/TraceFile.d.ts +54 -0
  30. package/dist/trace/TraceFile.js +86 -0
  31. package/dist/trace/TraceStore.d.ts +202 -0
  32. package/dist/trace/TraceStore.js +328 -0
  33. package/package.json +3 -1
  34. package/app/dist/client/assets/routes-TKgeFdSW.js +0 -5
@@ -8,6 +8,11 @@
8
8
  * Ingest never blocks on a consumer. The ring drops its oldest message when
9
9
  * full and the live `PubSub` is sliding, so neither a long-running program nor
10
10
  * a stalled webapp client can apply backpressure to the instrumented program.
11
+ *
12
+ * A session belongs to the first client instance that announced its ID. Only
13
+ * that instance's current connection may write to it or end it; a different
14
+ * instance reusing the ID is refused and counted in `Session.conflicts`, so a
15
+ * collision can never merge two runs or overwrite the original trace.
11
16
  */
12
17
  import { Context, Effect, Layer, PubSub } from 'effect';
13
18
  import * as Protocol from '../protocol/Schema.ts';
@@ -21,16 +26,40 @@ export interface SessionSnapshot {
21
26
  readonly droppedMessages: number;
22
27
  /** Lines received for this session that could not be decoded. */
23
28
  readonly skippedLines: number;
29
+ /**
30
+ * Whether the owner sent an instance ID, so a reused ID is refused and
31
+ * counted. `false` for an older client: a reused ID would have merged.
32
+ */
33
+ readonly conflictDetection: boolean;
24
34
  }
35
+ /**
36
+ * One client connection, as the store knows it: an identity token only.
37
+ *
38
+ * Passed with every write so the store can tell the owning connection from a
39
+ * stale one (a reconnect already replaced it) or a refused one (an ID
40
+ * collision).
41
+ */
42
+ export type Connection = symbol;
25
43
  declare const Store_base: Context.ServiceClass<Store, "effect-inspect/collector/Store", {
26
- /** Opens a session, or resumes the existing one when a program reconnects. */
27
- readonly hello: (message: Protocol.Hello) => Effect.Effect<void>;
28
- /** Records one decoded message and fans it out to live subscribers. */
29
- readonly append: (message: Protocol.ClientMessage) => Effect.Effect<void>;
30
- /** Counts one line that could not be decoded. */
31
- readonly skipLine: (sessionId: Protocol.SessionId | undefined) => Effect.Effect<void>;
32
- /** Marks a session ended because its program disconnected. */
33
- readonly end: (sessionId: Protocol.SessionId) => Effect.Effect<void>;
44
+ /**
45
+ * Opens a session for `connection`, or resumes it when the same client
46
+ * instance reconnects. A different instance announcing a known ID is a
47
+ * collision: it is refused and counted, and the session is left untouched.
48
+ */
49
+ readonly hello: (message: Protocol.Hello, connection: Connection) => Effect.Effect<void>;
50
+ /**
51
+ * Records one decoded message and fans it out to live subscribers — only
52
+ * when `connection` owns the message's session.
53
+ */
54
+ readonly append: (message: Protocol.ClientMessage, connection: Connection) => Effect.Effect<void>;
55
+ /** Counts one line that could not be decoded, if `connection` owns the session. */
56
+ readonly skipLine: (sessionId: Protocol.SessionId | undefined, connection: Connection) => Effect.Effect<void>;
57
+ /**
58
+ * Marks a session ended because `connection` closed. A no-op unless it is
59
+ * the owner's current connection, so neither a refused collision nor a
60
+ * connection a reconnect already replaced can end the owner's run.
61
+ */
62
+ readonly end: (sessionId: Protocol.SessionId, connection: Connection) => Effect.Effect<void>;
34
63
  /** Every known session, in the order they first said `Hello`. */
35
64
  readonly sessions: Effect.Effect<ReadonlyArray<Protocol.Session>>;
36
65
  /** One session's retained messages and loss counters. */
@@ -57,14 +86,25 @@ export declare class Store extends Store_base {
57
86
  export declare const make: (options?: {
58
87
  readonly capacity?: number;
59
88
  } | undefined) => Effect.Effect<{
60
- /** Opens a session, or resumes the existing one when a program reconnects. */
61
- readonly hello: (message: Protocol.Hello) => Effect.Effect<void>;
62
- /** Records one decoded message and fans it out to live subscribers. */
63
- readonly append: (message: Protocol.ClientMessage) => Effect.Effect<void>;
64
- /** Counts one line that could not be decoded. */
65
- readonly skipLine: (sessionId: Protocol.SessionId | undefined) => Effect.Effect<void>;
66
- /** Marks a session ended because its program disconnected. */
67
- readonly end: (sessionId: Protocol.SessionId) => Effect.Effect<void>;
89
+ /**
90
+ * Opens a session for `connection`, or resumes it when the same client
91
+ * instance reconnects. A different instance announcing a known ID is a
92
+ * collision: it is refused and counted, and the session is left untouched.
93
+ */
94
+ readonly hello: (message: Protocol.Hello, connection: Connection) => Effect.Effect<void>;
95
+ /**
96
+ * Records one decoded message and fans it out to live subscribers — only
97
+ * when `connection` owns the message's session.
98
+ */
99
+ readonly append: (message: Protocol.ClientMessage, connection: Connection) => Effect.Effect<void>;
100
+ /** Counts one line that could not be decoded, if `connection` owns the session. */
101
+ readonly skipLine: (sessionId: Protocol.SessionId | undefined, connection: Connection) => Effect.Effect<void>;
102
+ /**
103
+ * Marks a session ended because `connection` closed. A no-op unless it is
104
+ * the owner's current connection, so neither a refused collision nor a
105
+ * connection a reconnect already replaced can end the owner's run.
106
+ */
107
+ readonly end: (sessionId: Protocol.SessionId, connection: Connection) => Effect.Effect<void>;
68
108
  /** Every known session, in the order they first said `Hello`. */
69
109
  readonly sessions: Effect.Effect<ReadonlyArray<Protocol.Session>>;
70
110
  /** One session's retained messages and loss counters. */
@@ -8,6 +8,11 @@
8
8
  * Ingest never blocks on a consumer. The ring drops its oldest message when
9
9
  * full and the live `PubSub` is sliding, so neither a long-running program nor
10
10
  * a stalled webapp client can apply backpressure to the instrumented program.
11
+ *
12
+ * A session belongs to the first client instance that announced its ID. Only
13
+ * that instance's current connection may write to it or end it; a different
14
+ * instance reusing the ID is refused and counted in `Session.conflicts`, so a
15
+ * collision can never merge two runs or overwrite the original trace.
11
16
  */
12
17
  import { Clock, Context, Effect, Layer, PubSub } from 'effect';
13
18
  import * as Protocol from '../protocol/Schema.js';
@@ -29,17 +34,31 @@ export const make = Effect.fnUntraced(function* (options) {
29
34
  const sessions = new Map();
30
35
  const changes = yield* PubSub.sliding(1);
31
36
  const notify = PubSub.publish(changes, undefined).pipe(Effect.asVoid);
32
- const hello = (message) => Effect.gen(function* () {
37
+ const hello = (message, connection) => Effect.gen(function* () {
33
38
  const existing = sessions.get(message.sessionId);
34
39
  if (existing !== undefined) {
35
- // A reconnect resumes the session rather than starting a new one, so a
36
- // program that is killed and restarted keeps one continuous trace.
40
+ // Same instance means the client's socket dropped and it dialled back:
41
+ // resume, so one run keeps one continuous trace. Anything else is an
42
+ // independent run that chose the same ID. Refusing it keeps the
43
+ // original trace intact, and counting it lets a query report the
44
+ // collision rather than serve this run's data as the newcomer's.
45
+ if (existing.instanceId !== message.instanceId) {
46
+ existing.session = {
47
+ ...existing.session,
48
+ conflicts: (existing.session.conflicts ?? 0) + 1,
49
+ };
50
+ yield* notify;
51
+ return;
52
+ }
53
+ existing.connection = connection;
37
54
  existing.session = { ...existing.session, active: true };
38
55
  delete existing.session.endedAtEpochMillis;
39
56
  yield* notify;
40
57
  return;
41
58
  }
42
59
  sessions.set(message.sessionId, {
60
+ instanceId: message.instanceId,
61
+ connection,
43
62
  session: {
44
63
  sessionId: message.sessionId,
45
64
  program: message.program,
@@ -56,10 +75,16 @@ export const make = Effect.fnUntraced(function* (options) {
56
75
  });
57
76
  yield* notify;
58
77
  });
59
- const append = (message) => Effect.suspend(() => {
60
- const state = sessions.get(message.sessionId);
61
- // A message for a session that never said Hello has nowhere to go. It is
62
- // a client bug, not a reason to drop the connection.
78
+ /** The session `connection` currently owns under `sessionId`, if any. */
79
+ const owned = (sessionId, connection) => {
80
+ const state = sessionId === undefined ? undefined : sessions.get(sessionId);
81
+ return state?.connection === connection ? state : undefined;
82
+ };
83
+ const append = (message, connection) => Effect.suspend(() => {
84
+ const state = owned(message.sessionId, connection);
85
+ // A message for a session this connection does not own — it never said
86
+ // Hello, or it was refused as a collision — has nowhere to go. It is not
87
+ // a reason to drop the connection either.
63
88
  if (state === undefined)
64
89
  return Effect.void;
65
90
  if (state.ring.length < capacity) {
@@ -74,13 +99,13 @@ export const make = Effect.fnUntraced(function* (options) {
74
99
  });
75
100
  // A line from a connection that never sent a usable `Hello` has no session
76
101
  // to count it against, so it is skipped without a counter.
77
- const skipLine = (sessionId) => Effect.sync(() => {
78
- const state = sessionId === undefined ? undefined : sessions.get(sessionId);
102
+ const skipLine = (sessionId, connection) => Effect.sync(() => {
103
+ const state = owned(sessionId, connection);
79
104
  if (state !== undefined)
80
105
  state.skippedLines += 1;
81
106
  });
82
- const end = (sessionId) => Effect.flatMap(Clock.currentTimeMillis, (now) => {
83
- const state = sessions.get(sessionId);
107
+ const end = (sessionId, connection) => Effect.flatMap(Clock.currentTimeMillis, (now) => {
108
+ const state = owned(sessionId, connection);
84
109
  if (state === undefined || !state.session.active)
85
110
  return Effect.void;
86
111
  state.session = {
@@ -101,6 +126,7 @@ export const make = Effect.fnUntraced(function* (options) {
101
126
  : state.ring.slice(state.head).concat(state.ring.slice(0, state.head)),
102
127
  droppedMessages: state.droppedMessages,
103
128
  skippedLines: state.skippedLines,
129
+ conflictDetection: state.instanceId !== undefined,
104
130
  };
105
131
  });
106
132
  const live = (sessionId) => Effect.sync(() => sessions.get(sessionId)?.live);
@@ -61,6 +61,7 @@ export declare const clientCodec: Codec<{
61
61
  readonly startTime: bigint;
62
62
  readonly wallClockEpochMillis: number;
63
63
  };
64
+ readonly instanceId?: string | undefined;
64
65
  } | {
65
66
  readonly sessionId: string;
66
67
  readonly _tag: "SpanStart";
@@ -225,6 +226,7 @@ export declare const webappCodec: Codec<{
225
226
  };
226
227
  readonly active: boolean;
227
228
  readonly endedAtEpochMillis?: number | undefined;
229
+ readonly conflicts?: number | undefined;
228
230
  }[];
229
231
  } | {
230
232
  readonly sessionId: string;
@@ -240,6 +242,7 @@ export declare const webappCodec: Codec<{
240
242
  readonly startTime: bigint;
241
243
  readonly wallClockEpochMillis: number;
242
244
  };
245
+ readonly instanceId?: string | undefined;
243
246
  } | {
244
247
  readonly sessionId: string;
245
248
  readonly _tag: "SpanStart";
@@ -397,6 +400,7 @@ export declare const webappCodec: Codec<{
397
400
  readonly startTime: bigint;
398
401
  readonly wallClockEpochMillis: number;
399
402
  };
403
+ readonly instanceId?: string | undefined;
400
404
  } | {
401
405
  readonly sessionId: string;
402
406
  readonly _tag: "SpanStart";
@@ -569,7 +573,13 @@ export declare const traceFileHeaderCodec: Codec<{
569
573
  };
570
574
  readonly active: boolean;
571
575
  readonly endedAtEpochMillis?: number | undefined;
576
+ readonly conflicts?: number | undefined;
572
577
  };
573
578
  readonly savedAtEpochMillis: number;
579
+ readonly capture?: {
580
+ readonly droppedMessages: number;
581
+ readonly skippedLines: number;
582
+ readonly conflictDetection: boolean;
583
+ } | undefined;
574
584
  }>;
575
585
  export {};
@@ -34,9 +34,25 @@ export declare const Json: Schema.Codec<Json>;
34
34
  /** Span attributes, log annotations, and metric/event attributes. */
35
35
  export declare const Attributes: Schema.$Record<Schema.String, Schema.Codec<Json, Json, never, never>>;
36
36
  export type Attributes = Schema.Schema.Type<typeof Attributes>;
37
- /** Identifies one run of an instrumented program. */
37
+ /**
38
+ * Identifies one run of an instrumented program.
39
+ *
40
+ * Any string on the wire, so traces from older clients keep decoding. IDs a
41
+ * caller chooses are held to {@link isValidSessionId} by the client instead.
42
+ */
38
43
  export declare const SessionId: Schema.String;
39
44
  export type SessionId = Schema.Schema.Type<typeof SessionId>;
45
+ /** What {@link isValidSessionId} accepts, phrased for diagnostics. */
46
+ export declare const sessionIdRule = "1-128 ASCII letters, digits, \".\", \"_\" or \"-\", starting with a letter or digit";
47
+ /**
48
+ * Whether a caller-chosen session ID is acceptable, such as `checkout-before-1`.
49
+ *
50
+ * Human-readable rather than UUID-shaped, and needs no quoting as a shell
51
+ * argument. No colon, so a chosen ID can never take the `loaded:` form the
52
+ * webapp gives sessions read from a saved file. A generated UUID also
53
+ * satisfies it.
54
+ */
55
+ export declare const isValidSessionId: (id: string) => boolean;
40
56
  /** Mirrors `Tracer.SpanKind`. */
41
57
  export declare const SpanKind: Schema.Literals<readonly ["internal", "server", "client", "producer", "consumer"]>;
42
58
  export type SpanKind = Schema.Schema.Type<typeof SpanKind>;
@@ -67,6 +83,13 @@ export declare const Hello: Schema.Struct<{
67
83
  readonly startTime: Schema.BigIntFromString;
68
84
  readonly wallClockEpochMillis: Schema.Natural;
69
85
  }>;
86
+ /**
87
+ * Random per client instance, fixed for its lifetime and re-sent on every
88
+ * reconnect. Tells a genuine reconnect (same instance) from an independent
89
+ * run that chose the same `sessionId`. Absent from older clients, which are
90
+ * treated as one instance per `sessionId`.
91
+ */
92
+ readonly instanceId: Schema.optional<Schema.String>;
70
93
  }>;
71
94
  export type Hello = Schema.Schema.Type<typeof Hello>;
72
95
  /** The protocol version a `Hello` must carry. Bump on any breaking change. */
@@ -419,6 +442,13 @@ export declare const ClientMessage: Schema.Union<readonly [Schema.Struct<{
419
442
  readonly startTime: Schema.BigIntFromString;
420
443
  readonly wallClockEpochMillis: Schema.Natural;
421
444
  }>;
445
+ /**
446
+ * Random per client instance, fixed for its lifetime and re-sent on every
447
+ * reconnect. Tells a genuine reconnect (same instance) from an independent
448
+ * run that chose the same `sessionId`. Absent from older clients, which are
449
+ * treated as one instance per `sessionId`.
450
+ */
451
+ readonly instanceId: Schema.optional<Schema.String>;
422
452
  }>, Schema.Struct<{
423
453
  readonly sessionId: Schema.String;
424
454
  readonly _tag: Schema.tag<"SpanStart">;
@@ -563,6 +593,12 @@ export declare const Session: Schema.Struct<{
563
593
  }>;
564
594
  readonly active: Schema.Boolean;
565
595
  readonly endedAtEpochMillis: Schema.optional<Schema.Natural>;
596
+ /**
597
+ * Connections refused because a different client instance announced this
598
+ * `sessionId` — an ID collision. Their telemetry was discarded, so this
599
+ * session holds only the first instance's run. Absent when there were none.
600
+ */
601
+ readonly conflicts: Schema.optional<Schema.Natural>;
566
602
  }>;
567
603
  export type Session = Schema.Schema.Type<typeof Session>;
568
604
  /** Every session the collector holds. Sent on connect and on change. */
@@ -579,6 +615,12 @@ export declare const SessionList: Schema.Struct<{
579
615
  }>;
580
616
  readonly active: Schema.Boolean;
581
617
  readonly endedAtEpochMillis: Schema.optional<Schema.Natural>;
618
+ /**
619
+ * Connections refused because a different client instance announced this
620
+ * `sessionId` — an ID collision. Their telemetry was discarded, so this
621
+ * session holds only the first instance's run. Absent when there were none.
622
+ */
623
+ readonly conflicts: Schema.optional<Schema.Natural>;
582
624
  }>>;
583
625
  }>;
584
626
  export type SessionList = Schema.Schema.Type<typeof SessionList>;
@@ -603,6 +645,13 @@ export declare const Backlog: Schema.Struct<{
603
645
  readonly startTime: Schema.BigIntFromString;
604
646
  readonly wallClockEpochMillis: Schema.Natural;
605
647
  }>;
648
+ /**
649
+ * Random per client instance, fixed for its lifetime and re-sent on every
650
+ * reconnect. Tells a genuine reconnect (same instance) from an independent
651
+ * run that chose the same `sessionId`. Absent from older clients, which are
652
+ * treated as one instance per `sessionId`.
653
+ */
654
+ readonly instanceId: Schema.optional<Schema.String>;
606
655
  }>, Schema.Struct<{
607
656
  readonly sessionId: Schema.String;
608
657
  readonly _tag: Schema.tag<"SpanStart">;
@@ -743,6 +792,13 @@ export declare const Live: Schema.Struct<{
743
792
  readonly startTime: Schema.BigIntFromString;
744
793
  readonly wallClockEpochMillis: Schema.Natural;
745
794
  }>;
795
+ /**
796
+ * Random per client instance, fixed for its lifetime and re-sent on every
797
+ * reconnect. Tells a genuine reconnect (same instance) from an independent
798
+ * run that chose the same `sessionId`. Absent from older clients, which are
799
+ * treated as one instance per `sessionId`.
800
+ */
801
+ readonly instanceId: Schema.optional<Schema.String>;
746
802
  }>, Schema.Struct<{
747
803
  readonly sessionId: Schema.String;
748
804
  readonly _tag: Schema.tag<"SpanStart">;
@@ -898,6 +954,12 @@ export declare const WebappMessage: Schema.Union<readonly [Schema.Struct<{
898
954
  }>;
899
955
  readonly active: Schema.Boolean;
900
956
  readonly endedAtEpochMillis: Schema.optional<Schema.Natural>;
957
+ /**
958
+ * Connections refused because a different client instance announced this
959
+ * `sessionId` — an ID collision. Their telemetry was discarded, so this
960
+ * session holds only the first instance's run. Absent when there were none.
961
+ */
962
+ readonly conflicts: Schema.optional<Schema.Natural>;
901
963
  }>>;
902
964
  }>, Schema.Struct<{
903
965
  readonly sessionId: Schema.String;
@@ -913,6 +975,13 @@ export declare const WebappMessage: Schema.Union<readonly [Schema.Struct<{
913
975
  readonly startTime: Schema.BigIntFromString;
914
976
  readonly wallClockEpochMillis: Schema.Natural;
915
977
  }>;
978
+ /**
979
+ * Random per client instance, fixed for its lifetime and re-sent on every
980
+ * reconnect. Tells a genuine reconnect (same instance) from an independent
981
+ * run that chose the same `sessionId`. Absent from older clients, which are
982
+ * treated as one instance per `sessionId`.
983
+ */
984
+ readonly instanceId: Schema.optional<Schema.String>;
916
985
  }>, Schema.Struct<{
917
986
  readonly sessionId: Schema.String;
918
987
  readonly _tag: Schema.tag<"SpanStart">;
@@ -1050,6 +1119,13 @@ export declare const WebappMessage: Schema.Union<readonly [Schema.Struct<{
1050
1119
  readonly startTime: Schema.BigIntFromString;
1051
1120
  readonly wallClockEpochMillis: Schema.Natural;
1052
1121
  }>;
1122
+ /**
1123
+ * Random per client instance, fixed for its lifetime and re-sent on every
1124
+ * reconnect. Tells a genuine reconnect (same instance) from an independent
1125
+ * run that chose the same `sessionId`. Absent from older clients, which are
1126
+ * treated as one instance per `sessionId`.
1127
+ */
1128
+ readonly instanceId: Schema.optional<Schema.String>;
1053
1129
  }>, Schema.Struct<{
1054
1130
  readonly sessionId: Schema.String;
1055
1131
  readonly _tag: Schema.tag<"SpanStart">;
@@ -1200,6 +1276,23 @@ export declare const WebappRequest: Schema.Union<readonly [Schema.Struct<{
1200
1276
  readonly _tag: Schema.tag<"Unsubscribe">;
1201
1277
  }>]>;
1202
1278
  export type WebappRequest = Schema.Schema.Type<typeof WebappRequest>;
1279
+ /**
1280
+ * Collector-side loss counters for one session at snapshot time, carried in
1281
+ * a trace file exported from the collector.
1282
+ */
1283
+ export declare const TraceCapture: Schema.Struct<{
1284
+ /** Messages evicted by the collector's per-session capacity bound. */
1285
+ readonly droppedMessages: Schema.Natural;
1286
+ /** Lines received for the session that could not be decoded. */
1287
+ readonly skippedLines: Schema.Natural;
1288
+ /**
1289
+ * Whether the collector could refuse an independent run reusing this ID.
1290
+ * `false` when the owning client predates instance IDs, so a reused ID
1291
+ * would have merged silently and `session.conflicts` proves nothing.
1292
+ */
1293
+ readonly conflictDetection: Schema.Boolean;
1294
+ }>;
1295
+ export type TraceCapture = Schema.Schema.Type<typeof TraceCapture>;
1203
1296
  /**
1204
1297
  * Line 1 of a saved trace file.
1205
1298
  *
@@ -1229,8 +1322,32 @@ export declare const TraceFileHeader: Schema.Struct<{
1229
1322
  }>;
1230
1323
  readonly active: Schema.Boolean;
1231
1324
  readonly endedAtEpochMillis: Schema.optional<Schema.Natural>;
1325
+ /**
1326
+ * Connections refused because a different client instance announced this
1327
+ * `sessionId` — an ID collision. Their telemetry was discarded, so this
1328
+ * session holds only the first instance's run. Absent when there were none.
1329
+ */
1330
+ readonly conflicts: Schema.optional<Schema.Natural>;
1232
1331
  }>;
1233
1332
  readonly savedAtEpochMillis: Schema.Natural;
1333
+ /**
1334
+ * What the collector knew about loss when the file was exported from it.
1335
+ * Absent from browser saves and older files: their capture completeness is
1336
+ * unknown, not complete. Optional and additive, so older builds, which
1337
+ * ignore unknown header keys, still read these files.
1338
+ */
1339
+ readonly capture: Schema.optional<Schema.Struct<{
1340
+ /** Messages evicted by the collector's per-session capacity bound. */
1341
+ readonly droppedMessages: Schema.Natural;
1342
+ /** Lines received for the session that could not be decoded. */
1343
+ readonly skippedLines: Schema.Natural;
1344
+ /**
1345
+ * Whether the collector could refuse an independent run reusing this ID.
1346
+ * `false` when the owning client predates instance IDs, so a reused ID
1347
+ * would have merged silently and `session.conflicts` proves nothing.
1348
+ */
1349
+ readonly conflictDetection: Schema.Boolean;
1350
+ }>>;
1234
1351
  }>;
1235
1352
  export type TraceFileHeader = Schema.Schema.Type<typeof TraceFileHeader>;
1236
1353
  /** The trace file layout version. Bump only on a change to the header or the line layout. */
@@ -28,8 +28,24 @@ export const Json = Schema.Union([
28
28
  ]);
29
29
  /** Span attributes, log annotations, and metric/event attributes. */
30
30
  export const Attributes = Schema.Record(Schema.String, Json);
31
- /** Identifies one run of an instrumented program. */
31
+ /**
32
+ * Identifies one run of an instrumented program.
33
+ *
34
+ * Any string on the wire, so traces from older clients keep decoding. IDs a
35
+ * caller chooses are held to {@link isValidSessionId} by the client instead.
36
+ */
32
37
  export const SessionId = Schema.String;
38
+ /** What {@link isValidSessionId} accepts, phrased for diagnostics. */
39
+ export const sessionIdRule = '1-128 ASCII letters, digits, ".", "_" or "-", starting with a letter or digit';
40
+ /**
41
+ * Whether a caller-chosen session ID is acceptable, such as `checkout-before-1`.
42
+ *
43
+ * Human-readable rather than UUID-shaped, and needs no quoting as a shell
44
+ * argument. No colon, so a chosen ID can never take the `loaded:` form the
45
+ * webapp gives sessions read from a saved file. A generated UUID also
46
+ * satisfies it.
47
+ */
48
+ export const isValidSessionId = (id) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id);
33
49
  const sessionId = { sessionId: SessionId };
34
50
  /** Mirrors `Tracer.SpanKind`. */
35
51
  export const SpanKind = Schema.Literals(['internal', 'server', 'client', 'producer', 'consumer']);
@@ -64,6 +80,13 @@ export const Hello = Schema.Struct({
64
80
  runtime: Schema.String,
65
81
  protocolVersion: Schema.Natural,
66
82
  clock: Clock,
83
+ /**
84
+ * Random per client instance, fixed for its lifetime and re-sent on every
85
+ * reconnect. Tells a genuine reconnect (same instance) from an independent
86
+ * run that chose the same `sessionId`. Absent from older clients, which are
87
+ * treated as one instance per `sessionId`.
88
+ */
89
+ instanceId: Schema.optional(Schema.String),
67
90
  });
68
91
  /** The protocol version a `Hello` must carry. Bump on any breaking change. */
69
92
  export const protocolVersion = 1;
@@ -249,6 +272,12 @@ export const Session = Schema.Struct({
249
272
  clock: Clock,
250
273
  active: Schema.Boolean,
251
274
  endedAtEpochMillis: Schema.optional(Schema.Natural),
275
+ /**
276
+ * Connections refused because a different client instance announced this
277
+ * `sessionId` — an ID collision. Their telemetry was discarded, so this
278
+ * session holds only the first instance's run. Absent when there were none.
279
+ */
280
+ conflicts: Schema.optional(Schema.Natural),
252
281
  });
253
282
  /** Every session the collector holds. Sent on connect and on change. */
254
283
  export const SessionList = Schema.Struct({
@@ -302,6 +331,22 @@ export const Unsubscribe = Schema.Struct({
302
331
  });
303
332
  /** What the webapp sends to the collector. */
304
333
  export const WebappRequest = Schema.Union([Subscribe, Unsubscribe]);
334
+ /**
335
+ * Collector-side loss counters for one session at snapshot time, carried in
336
+ * a trace file exported from the collector.
337
+ */
338
+ export const TraceCapture = Schema.Struct({
339
+ /** Messages evicted by the collector's per-session capacity bound. */
340
+ droppedMessages: Schema.Natural,
341
+ /** Lines received for the session that could not be decoded. */
342
+ skippedLines: Schema.Natural,
343
+ /**
344
+ * Whether the collector could refuse an independent run reusing this ID.
345
+ * `false` when the owning client predates instance IDs, so a reused ID
346
+ * would have merged silently and `session.conflicts` proves nothing.
347
+ */
348
+ conflictDetection: Schema.Boolean,
349
+ });
305
350
  /**
306
351
  * Line 1 of a saved trace file.
307
352
  *
@@ -322,6 +367,13 @@ export const TraceFileHeader = Schema.Struct({
322
367
  protocolVersion: Schema.Natural,
323
368
  session: Session,
324
369
  savedAtEpochMillis: Schema.Natural,
370
+ /**
371
+ * What the collector knew about loss when the file was exported from it.
372
+ * Absent from browser saves and older files: their capture completeness is
373
+ * unknown, not complete. Optional and additive, so older builds, which
374
+ * ignore unknown header keys, still read these files.
375
+ */
376
+ capture: Schema.optional(TraceCapture),
325
377
  });
326
378
  /** The trace file layout version. Bump only on a change to the header or the line layout. */
327
379
  export const traceFileFormatVersion = 1;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Calls a running collector's query API (see `collector/QueryApi.ts`).
3
+ *
4
+ * Never fails: transport problems become the same `QueryFailure` shape the
5
+ * collector returns, tagged `CollectorUnavailable` (nothing answered in time)
6
+ * or `CollectorError` (something answered, but not this API), so a caller
7
+ * prints one JSON contract whatever went wrong.
8
+ */
9
+ import { Effect } from 'effect';
10
+ import { HttpClient } from 'effect/unstable/http';
11
+ import * as Query from './Query.ts';
12
+ /** Where `effect-inspect start` listens by default. */
13
+ export declare const defaultUrl = "http://localhost:34437";
14
+ /** How long a call waits for the collector before `CollectorUnavailable`. */
15
+ export declare const defaultTimeoutMs = 5000;
16
+ export interface Options {
17
+ /** Collector base URL, e.g. {@link defaultUrl}. */
18
+ readonly url: string;
19
+ readonly timeoutMs?: number | undefined;
20
+ }
21
+ /** Sends one query request (validated by the collector) and returns its response. */
22
+ export declare const query: (options: Options, request: unknown) => Effect.Effect<Query.QueryResponse, never, HttpClient.HttpClient>;
23
+ /**
24
+ * Downloads one session's frozen snapshot as `.eitrace` text, loss counters
25
+ * included, for offline queries that answer exactly as the live one did.
26
+ */
27
+ export declare const exportTrace: (options: Options, sessionId: string) => Effect.Effect<{
28
+ readonly ok: true;
29
+ readonly text: string;
30
+ } | Query.QueryFailure, never, HttpClient.HttpClient>;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Calls a running collector's query API (see `collector/QueryApi.ts`).
3
+ *
4
+ * Never fails: transport problems become the same `QueryFailure` shape the
5
+ * collector returns, tagged `CollectorUnavailable` (nothing answered in time)
6
+ * or `CollectorError` (something answered, but not this API), so a caller
7
+ * prints one JSON contract whatever went wrong.
8
+ */
9
+ import { Duration, Effect } from 'effect';
10
+ import { HttpClient, HttpClientRequest } from 'effect/unstable/http';
11
+ import { defaultPort } from '../collector/Config.js';
12
+ import * as Query from './Query.js';
13
+ /** Where `effect-inspect start` listens by default. */
14
+ export const defaultUrl = `http://localhost:${defaultPort}`;
15
+ /** How long a call waits for the collector before `CollectorUnavailable`. */
16
+ export const defaultTimeoutMs = 5000;
17
+ const unavailable = (op, url, detail) => Query.failure(op, 'CollectorUnavailable', `No collector answered at ${url}: ${detail}`, 'Start one with `effect-inspect start` (EFFECT_INSPECT_PORT sets its port) or point at the right URL; for saved traces query the file instead.', { url });
18
+ const foreign = (op, url, detail) => Query.failure(op, 'CollectorError', `${url} did not answer as an effect-inspect query API: ${detail}`, 'The collector may predate the query API, or another service owns that port. Upgrade and restart the collector, or check the URL.', { url });
19
+ const isResponse = (body) => typeof body === 'object' &&
20
+ body !== null &&
21
+ body.apiVersion === Query.apiVersion &&
22
+ typeof body.ok === 'boolean';
23
+ const opOf = (request) => typeof request === 'object' &&
24
+ request !== null &&
25
+ typeof request.op === 'string'
26
+ ? request.op
27
+ : null;
28
+ const withTimeout = (options, op, effect) => {
29
+ const timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
30
+ return Effect.timeoutOrElse(effect, {
31
+ duration: Duration.millis(timeoutMs),
32
+ orElse: () => Effect.succeed(unavailable(op, options.url, `no answer within ${timeoutMs}ms`)),
33
+ });
34
+ };
35
+ /** Sends one query request (validated by the collector) and returns its response. */
36
+ export const query = (options, request) => {
37
+ const op = opOf(request);
38
+ // Bounded here too: a failure built from a long URL, or an oversized body
39
+ // from a collector that does not enforce the bound, must not escape it.
40
+ return Effect.map(withTimeout(options, op, Effect.gen(function* () {
41
+ const client = yield* HttpClient.HttpClient;
42
+ const response = yield* client.execute(HttpClientRequest.post(new URL('/api/v1/query', options.url)).pipe(HttpClientRequest.bodyJsonUnsafe(request)));
43
+ const body = yield* response.json;
44
+ return isResponse(body)
45
+ ? body
46
+ : foreign(op, options.url, `unexpected body (HTTP ${response.status})`);
47
+ }).pipe(Effect.catchTag('HttpClientError', (error) => Effect.succeed(error.reason._tag === 'TransportError' || error.reason._tag === 'InvalidUrlError'
48
+ ? unavailable(op, options.url, error.message)
49
+ : foreign(op, options.url, error.message))))), Query.limitResponse);
50
+ };
51
+ /**
52
+ * Downloads one session's frozen snapshot as `.eitrace` text, loss counters
53
+ * included, for offline queries that answer exactly as the live one did.
54
+ */
55
+ export const exportTrace = (options, sessionId) => Effect.map(withTimeout(options, 'export', Effect.gen(function* () {
56
+ const client = yield* HttpClient.HttpClient;
57
+ const url = new URL('/api/v1/export', options.url);
58
+ url.searchParams.set('sessionId', sessionId);
59
+ const response = yield* client.execute(HttpClientRequest.get(url));
60
+ // An older collector hands unknown paths to the web UI, which may answer 200 HTML.
61
+ if (response.status === 200 &&
62
+ response.headers['content-type']?.startsWith('application/x-ndjson') === true) {
63
+ return { ok: true, text: yield* response.text };
64
+ }
65
+ const body = yield* response.json;
66
+ return isResponse(body) && !body.ok
67
+ ? body
68
+ : foreign('export', options.url, `unexpected body (HTTP ${response.status})`);
69
+ }).pipe(Effect.catchTag('HttpClientError', (error) => Effect.succeed(error.reason._tag === 'TransportError' || error.reason._tag === 'InvalidUrlError'
70
+ ? unavailable('export', options.url, error.message)
71
+ : foreign('export', options.url, error.message))))),
72
+ // The trace text is a lossless artifact and deliberately unbounded;
73
+ // only failures are held to the JSON response bound.
74
+ (result) => (result.ok ? result : Query.limitResponse(result)));