@reclaimprotocol/agent 0.1.25 → 0.1.27

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.
@@ -1886,6 +1886,50 @@ export const MCP_TOOLS = {
1886
1886
  "additionalProperties": false
1887
1887
  }),
1888
1888
  },
1889
+ "ImportDevtoolsProvider": {
1890
+ description: "Import a provider from devtools\n\nImport the latest version of a public provider from the legacy devtools\nservice. Use this when moving an existing provider to Builder instead\nof recreating it.\n\nThe import preserves the legacy provider UUID. The server reads the\ncurrent configuration from\n`https://api.reclaimprotocol.org/api/providers/{providerId}` and\ntranslates it to the Builder schema.\n\nIf the provider already exists in the seeded anonymous catalog, the\nimport assigns that record to the target organization and adds the\nimported version idempotently. A provider owned by another Builder\norganization returns `409`.\n",
1891
+ inputSchema: toZod({
1892
+ "type": "object",
1893
+ "properties": {
1894
+ "params": {
1895
+ "type": "object",
1896
+ "properties": {
1897
+
1898
+ }
1899
+ },
1900
+ "query": {
1901
+ "type": "object",
1902
+ "properties": {
1903
+
1904
+ }
1905
+ },
1906
+ "body": {
1907
+ "type": "object",
1908
+ "additionalProperties": false,
1909
+ "required": [
1910
+ "orgId",
1911
+ "providerId"
1912
+ ],
1913
+ "properties": {
1914
+ "orgId": {
1915
+ "type": "string",
1916
+ "format": "uuid"
1917
+ },
1918
+ "providerId": {
1919
+ "type": "string",
1920
+ "format": "uuid"
1921
+ }
1922
+ }
1923
+ }
1924
+ },
1925
+ "required": [
1926
+ "params",
1927
+ "query",
1928
+ "body"
1929
+ ],
1930
+ "additionalProperties": false
1931
+ }),
1932
+ },
1889
1933
  "IssueOrgToken": {
1890
1934
  description: "Issue a new organization API key\n\nIssues a fresh per-organization API key (`rorg_…`). The caller must be an active **OWNER** of the organization. An org may hold several keys; issuing a new one does **not** invalidate existing keys (revoke those explicitly). The plaintext key is returned **only once** in this response (the server stores only its HMAC); treat it like a password.",
1891
1935
  inputSchema: toZod({
@@ -71,6 +71,8 @@ const SHARED_TOPIC_FILES: Record<string, string> = {
71
71
  const TOPIC_SUMMARIES: Record<string, string> = {
72
72
  'overview': 'Terminology, your stance, and the whole loop at a glance.',
73
73
  'auth': 'Signing in to the backend, and which token authorizes what.',
74
+ 'devtools-migration': 'Importing a public legacy provider with the same '
75
+ + 'UUID.',
74
76
  'browser': 'Choosing a browser source (local Chrome, the Reclaim runtime in '
75
77
  + 'Docker, a remote Popcorn browser, or a custom CDP endpoint), '
76
78
  + 'geolocation, live view, and launch troubleshooting.',
@@ -113,7 +113,9 @@ export function liveViewTools(
113
113
  + '"container".',
114
114
  ))
115
115
 
116
- const tunnel = await startTunnel(viewerPort)
116
+ const tunnel = await startTunnel(viewerPort, {
117
+ probePath: '/liveview.html?magnify=1',
118
+ })
117
119
  // The path is the runtime's own viewer page; `magnify=1` is what makes
118
120
  // it lay out for the device that opens it.
119
121
  const url = `${tunnel.url}/liveview.html?magnify=1`
@@ -128,30 +130,11 @@ export function liveViewTools(
128
130
  ...(share.tunnel.binary.source === 'downloaded'
129
131
  ? { cloudflaredInstalled: true }
130
132
  : {}),
131
- ...(share.tunnel.ready
132
- ? {}
133
- : {
134
- propagating: true,
135
- // cloudflared's own last words. Without them, "the URL does
136
- // not connect" is unactionable: the tunnel, the local server
137
- // and this machine's egress all look identical from outside.
138
- ...(share.tunnel.diagnostics
139
- ? { cloudflaredLog: share.tunnel.diagnostics }
140
- : {}),
141
- }),
142
133
  _notes: [
143
134
  ...(share.tunnel.binary.source === 'downloaded'
144
135
  ? ['cloudflared was not installed, so it was downloaded once '
145
136
  + 'to ~/.reclaim/bin. Mention that; later shares reuse it.']
146
137
  : []),
147
- ...(share.tunnel.ready
148
- ? []
149
- : ['The tunnel never confirmed a connection to Cloudflare, so '
150
- + 'the URL may not resolve yet. Give it ~10s and reload '
151
- + 'first. If it still fails, say so and offer to retry '
152
- + '(stop_browser_view then share_browser_view) — a quick '
153
- + 'tunnel does sometimes fail to register. '
154
- + '`cloudflaredLog` has its output if it keeps happening.']),
155
138
  'Relay `url` to the developer verbatim, on its own line.',
156
139
  'The URL is a credential: it needs no login, and anyone with it '
157
140
  + 'can see this browser and act in it as the signed-in user.',
package/src/old/client.ts CHANGED
@@ -99,6 +99,80 @@ export interface LinkNonce {
99
99
  expiresInSeconds: number
100
100
  }
101
101
 
102
+ /** Log-level filter accepted by the devtools backend's session-logs route.
103
+ * It validates against this exact list and rejects anything else with a 400,
104
+ * including the comma-separated lists the log-stream service itself would
105
+ * accept. Each value is hierarchical at the log-stream end: `fine` means
106
+ * FINE+CONFIG+INFO+WARNING+SEVERE, `info` means INFO+WARNING+SEVERE, and so
107
+ * on. `finer`/`finest` (the PII tiers) are not reachable through this route
108
+ * at all. */
109
+ export type SessionLogLevel =
110
+ | 'fine'
111
+ | 'config'
112
+ | 'info'
113
+ | 'warning'
114
+ | 'severe'
115
+ | 'unknown'
116
+
117
+ /** Filters for {@link ReclaimOldClient.getSessionLogs}. Every one is optional
118
+ * and forwarded verbatim; the session id itself is the route's path param, so
119
+ * `providerId`/`deviceId` narrow WITHIN one session rather than searching
120
+ * across sessions. */
121
+ export interface SessionLogsQuery {
122
+ deviceId?: string
123
+ source?: string
124
+ /** The client-side logger NAME (log-stream's `log_type` column, the SDK's
125
+ * `LogEntry.type`) — not the event type. */
126
+ logType?: string
127
+ logLevel?: SessionLogLevel
128
+ providerId?: string
129
+ /** ISO-8601. Omitting BOTH bounds makes the log-stream service default to
130
+ * the last 3 days. */
131
+ startTime?: string
132
+ endTime?: string
133
+ /** Substring match against the log line (`LIKE %…%`). */
134
+ logLine?: string
135
+ /** Exact `LogEventType` name, for example `REQUEST_MATCHED`. */
136
+ eventType?: string
137
+ /** Capped at 1000 by the log-stream service. */
138
+ limit?: number
139
+ offset?: number
140
+ includeCount?: boolean
141
+ }
142
+
143
+ /** One raw log line, in the log-stream service's snake_case ClickHouse shape,
144
+ * passed through unchanged by the devtools backend. `event_type` is the SDK's
145
+ * `LogEventType` milestone marker (`REQUEST_MATCHED`,
146
+ * `CLAIM_CREATION_STARTED`, `PROOF_GENERATED`, …); it is `''` on the majority
147
+ * of lines, which carry no event. */
148
+ export interface SessionLogRow {
149
+ timestamp: string
150
+ request_id: string
151
+ session_id: string
152
+ device_id: string
153
+ source: string
154
+ app_id: string
155
+ provider_id: string
156
+ log_type: string
157
+ log_level: string
158
+ log_line: string
159
+ event_type?: string
160
+ metadata?: string
161
+ }
162
+
163
+ /** `GET /api/logger/:sessionId`'s `data` envelope. The backend queries every
164
+ * region that holds the session, concatenates the pages, sorts them
165
+ * newest-first and re-slices to `limit`, so `data` is one page and
166
+ * `totalCount` is the match count across all of them. */
167
+ export interface SessionLogsPage {
168
+ data: SessionLogRow[]
169
+ count: number
170
+ totalCount?: number
171
+ /** Regions whose log-stream query failed — the page is INCOMPLETE when
172
+ * this is present. */
173
+ failedRegions?: string[]
174
+ }
175
+
102
176
  interface RequestOpts {
103
177
  query?: Record<string, string | number | undefined>
104
178
  body?: unknown
@@ -212,6 +286,60 @@ export class ReclaimOldClient {
212
286
  return json
213
287
  }
214
288
 
289
+ /**
290
+ * Raw log LINES for a verification session — the SDK's own diagnostic
291
+ * logging, the thing the dashboard's session "logs" tab renders, and a
292
+ * strictly richer signal than `getSessionAnalyticsLogs`'s milestone events.
293
+ *
294
+ * This is the devtools backend (`GET /api/logger/:sessionId`), NOT the
295
+ * analytics-logs host — hence `#request`, and hence the same identity and
296
+ * ownership rules as every other authed call here: the session's app must
297
+ * be one you created (admins excepted), or the route 404s.
298
+ *
299
+ * The backend fans the query out to the per-region log-stream services,
300
+ * which read the ClickHouse `logs` table. Two defaults applied down there
301
+ * bite hard if you don't override them:
302
+ * - no `startTime`/`endTime` → only the last 3 days are searched;
303
+ * - no `logLevel` → INFO and above only, so every FINE/CONFIG line is
304
+ * invisible.
305
+ * Entries are dropped entirely after 30 days (table TTL).
306
+ */
307
+ async getSessionLogs(
308
+ sessionId: string,
309
+ query: SessionLogsQuery = {},
310
+ ): Promise<SessionLogsPage> {
311
+ const res = await this.#request(
312
+ 'GET',
313
+ `/logger/${encodeURIComponent(sessionId)}`,
314
+ {
315
+ auth: true,
316
+ query: {
317
+ deviceId: query.deviceId,
318
+ source: query.source,
319
+ logType: query.logType,
320
+ logLevel: query.logLevel,
321
+ providerId: query.providerId,
322
+ startTime: query.startTime,
323
+ endTime: query.endTime,
324
+ logLine: query.logLine,
325
+ eventType: query.eventType,
326
+ limit: query.limit,
327
+ offset: query.offset,
328
+ includeCount: query.includeCount === undefined
329
+ ? undefined
330
+ : String(query.includeCount),
331
+ },
332
+ },
333
+ )
334
+ const page = (res as { data?: Partial<SessionLogsPage> }).data
335
+ return {
336
+ data: page?.data ?? [],
337
+ count: page?.count ?? page?.data?.length ?? 0,
338
+ totalCount: page?.totalCount,
339
+ failedRegions: page?.failedRegions,
340
+ }
341
+ }
342
+
215
343
  /** Mint a brand-new, unassociated (sandboxed) app — no auth required. Link
216
344
  * it to an account afterwards with `getLinkNonce` + `linkAppToAccount`, or
217
345
  * use it as-is within the sandbox session limit. */
@@ -12,6 +12,7 @@ import { getMeProvidersTool } from './me-providers.ts'
12
12
  import { createVersionFromCaptureTool } from './publish.ts'
13
13
  import { scaffoldDemoServerTool } from './scaffold-demo-server.ts'
14
14
  import { sessionAnalyticsLogsTool } from './session-analytics-logs.ts'
15
+ import { sessionLogsTool } from './session-logs.ts'
15
16
  import { updateProviderMetadataTool } from './update-provider-metadata.ts'
16
17
  import { verifySdkSetupTool } from './verify-sdk-setup.ts'
17
18
 
@@ -41,6 +42,7 @@ export function buildOldBackendTools(
41
42
  getProviderInfoTool(sdkClient),
42
43
  updateProviderMetadataTool(client),
43
44
  sessionAnalyticsLogsTool(client),
45
+ sessionLogsTool(client),
44
46
  issueAppCredentialsTool(client),
45
47
  linkAppTool(client),
46
48
  checkAppStatusTool(client),
@@ -0,0 +1,395 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { dirname, isAbsolute, join } from 'node:path'
3
+ import { defineTool, type RegisteredTool } from '../../mcp/server.ts'
4
+ import { findProjectRoot } from '../../paths.ts'
5
+ import type {
6
+ ReclaimOldClient,
7
+ SessionLogRow,
8
+ SessionLogsQuery,
9
+ } from '../client.ts'
10
+
11
+ /** The log-stream service caps a page at 1000 entries however large a
12
+ * `limit` asks for, so paging is the only way past it. */
13
+ const MAX_PAGE = 1000
14
+
15
+ /** Inline default. Small enough to stay readable in a tool result; a bigger
16
+ * pull belongs in `saveTo`. */
17
+ const DEFAULT_LIMIT = 200
18
+
19
+ /** Ceiling on a `saveTo` dump, so a chatty session can't page forever. */
20
+ const DEFAULT_MAX_ENTRIES = 20_000
21
+
22
+ interface Args extends SessionLogsQuery {
23
+ sessionId: string
24
+ /** Substring match on the log line — `logLine` under its dashboard name. */
25
+ contains?: string
26
+ /** Keep only entries that carry an `event_type`. Applied here rather than
27
+ * server-side: the devtools route has no `eventTypeOnly` passthrough. */
28
+ eventsOnly?: boolean
29
+ saveTo?: string
30
+ maxEntries?: number
31
+ }
32
+
33
+ /** An entry's fields minus the ones hoisted into `context`. `event` and
34
+ * `metadata` drop out when empty; `device` and `source` appear only on an
35
+ * entry that disagrees with a single-valued context. */
36
+ interface CompactEntry {
37
+ ts: string
38
+ level: string
39
+ event?: string
40
+ logger: string
41
+ line: string
42
+ device?: string
43
+ source?: string
44
+ metadata?: string
45
+ }
46
+
47
+ export function sessionLogsTool(client: ReclaimOldClient): RegisteredTool {
48
+ return defineTool<Args>(
49
+ {
50
+ name: 'session_logs',
51
+ description:
52
+ 'Fetch the raw log entries the in-app SDK emitted during a '
53
+ + 'verification session, each with its event type (for example '
54
+ + 'REQUEST_MATCHED, CLAIM_CREATION_STARTED, or PROOF_GENERATED), '
55
+ + 'log level, and logger name. This is the data behind the '
56
+ + 'dashboard session-logs tab, and it is far more detailed than '
57
+ + 'session_analytics_logs, which returns only the coarse milestone '
58
+ + 'events. Use it to diagnose a session that stalled, matched '
59
+ + 'nothing, or produced no claim. Narrow the result with eventType, '
60
+ + 'logLevel, or contains; page through it with limit and offset; or '
61
+ + 'set saveTo to write every matching entry to an NDJSON file and '
62
+ + 'read that file with your own tools. Old-devtools mode only. You '
63
+ + 'must own the app the session belongs to, so call '
64
+ + 'reclaim_authenticate first.',
65
+ inputSchema: {
66
+ type: 'object',
67
+ properties: {
68
+ sessionId: {
69
+ type: 'string',
70
+ description:
71
+ 'Id of the verification session to read log entries for.',
72
+ },
73
+ eventType: {
74
+ type: 'string',
75
+ description:
76
+ 'Keep only entries that carry this exact event type, for '
77
+ + 'example REQUEST_MATCHED or PROOF_GENERATION_FAILED_'
78
+ + 'EXCEPTION. Most entries carry no event type at all, so '
79
+ + 'prefer eventsOnly when you want the milestone timeline '
80
+ + 'rather than guessing a name.',
81
+ },
82
+ eventsOnly: {
83
+ type: 'boolean',
84
+ description:
85
+ 'Keep only entries that carry an event type, which gives you '
86
+ + 'a compact milestone timeline for the session. This tool '
87
+ + 'applies the filter after fetching, so it narrows what you '
88
+ + 'read rather than what it fetches.',
89
+ },
90
+ logLevel: {
91
+ type: 'string',
92
+ enum: ['fine', 'config', 'info', 'warning', 'severe', 'unknown'],
93
+ description:
94
+ 'Lowest severity to include. Each value is hierarchical: '
95
+ + '`fine` includes FINE, CONFIG, INFO, WARNING, and SEVERE; '
96
+ + '`info` drops FINE and CONFIG; `severe` keeps only '
97
+ + 'failures. Defaults to `fine`, which is wider than the '
98
+ + 'backend default of `info`. The FINER and FINEST tiers '
99
+ + 'contain PII, and this route rejects them.',
100
+ },
101
+ logType: {
102
+ type: 'string',
103
+ description:
104
+ 'Exact client-side logger name, which the SDK calls `type` — '
105
+ + 'for example reclaim_flutter_sdk.ClaimCreation. This is not '
106
+ + 'the event type.',
107
+ },
108
+ contains: {
109
+ type: 'string',
110
+ description:
111
+ 'Substring that an entry\'s log line must contain.',
112
+ },
113
+ providerId: {
114
+ type: 'string',
115
+ description:
116
+ 'Keep only entries for this provider. A session can touch '
117
+ + 'more than one provider, so this narrows within the '
118
+ + 'session; it cannot search across sessions.',
119
+ },
120
+ deviceId: {
121
+ type: 'string',
122
+ description:
123
+ 'Keep only entries for this device id, which is the claimant '
124
+ + 'id.',
125
+ },
126
+ source: {
127
+ type: 'string',
128
+ description:
129
+ 'Keep only entries from this client build. The source string '
130
+ + 'carries the SDK, platform, and version, so it is the field '
131
+ + 'to compare a working run against a broken one.',
132
+ },
133
+ startTime: {
134
+ type: 'string',
135
+ description:
136
+ 'Lower bound, as an ISO-8601 timestamp. If you set neither '
137
+ + 'bound, the backend searches only the last 3 days.',
138
+ },
139
+ endTime: {
140
+ type: 'string',
141
+ description: 'Upper bound, as an ISO-8601 timestamp.',
142
+ },
143
+ limit: {
144
+ type: 'number',
145
+ description:
146
+ `Log entries per page. Defaults to ${DEFAULT_LIMIT} and is `
147
+ + `capped at ${MAX_PAGE}. Ignored when you set saveTo.`,
148
+ },
149
+ offset: {
150
+ type: 'number',
151
+ description:
152
+ 'Entries to skip, counting back from the newest match. '
153
+ + 'Ignored when you set saveTo.',
154
+ },
155
+ saveTo: {
156
+ type: 'string',
157
+ description:
158
+ 'Path to write every matching entry to, as NDJSON: one raw '
159
+ + 'entry per line, oldest first. When you set this, the tool '
160
+ + 'returns a summary instead of the entries. Relative paths '
161
+ + 'resolve against the project root. Use it when a session '
162
+ + 'has more entries than you want to read inline.',
163
+ },
164
+ maxEntries: {
165
+ type: 'number',
166
+ description:
167
+ 'Maximum entries a saveTo dump collects. Defaults to '
168
+ + `${DEFAULT_MAX_ENTRIES}.`,
169
+ },
170
+ },
171
+ required: ['sessionId'],
172
+ },
173
+ },
174
+ async(args) => {
175
+ // The backend's own default is `info`, which silently hides every
176
+ // FINE and CONFIG entry — the ones that say what the interceptor
177
+ // actually saw. Debugging is the whole point of this tool, so widen
178
+ // the default here.
179
+ const query: SessionLogsQuery = {
180
+ logLevel: (args.logLevel ?? 'fine'),
181
+ logType: args.logType,
182
+ eventType: args.eventType,
183
+ providerId: args.providerId,
184
+ deviceId: args.deviceId,
185
+ source: args.source,
186
+ startTime: args.startTime,
187
+ endTime: args.endTime,
188
+ logLine: args.contains,
189
+ }
190
+
191
+ return args.saveTo
192
+ ? await dump(client, args, query, args.saveTo)
193
+ : await page(client, args, query)
194
+ },
195
+ )
196
+ }
197
+
198
+ /** One page, returned inline. */
199
+ async function page(
200
+ client: ReclaimOldClient,
201
+ args: Args,
202
+ query: SessionLogsQuery,
203
+ ) {
204
+ const limit = clampLimit(args.limit ?? DEFAULT_LIMIT)
205
+ const offset = Math.max(args.offset ?? 0, 0)
206
+ const res = await client.getSessionLogs(args.sessionId, {
207
+ ...query,
208
+ limit,
209
+ offset,
210
+ includeCount: true,
211
+ })
212
+
213
+ // Newest-first off the wire; a debugger reads a session forwards.
214
+ const chronological = [...res.data].reverse()
215
+ const entries = args.eventsOnly
216
+ ? chronological.filter(hasEvent)
217
+ : chronological
218
+ const fetched = res.data.length
219
+ const total = res.totalCount
220
+
221
+ return {
222
+ ...summarize(args, res.failedRegions, entries, total),
223
+ page: {
224
+ limit,
225
+ offset,
226
+ fetched,
227
+ ...(args.eventsOnly ? { keptWithEvent: entries.length } : {}),
228
+ hasMore: total === undefined
229
+ ? fetched === limit
230
+ : offset + fetched < total,
231
+ nextOffset: offset + fetched,
232
+ },
233
+ entries: compact(entries),
234
+ }
235
+ }
236
+
237
+ /** Every match, paged out to an NDJSON file. */
238
+ async function dump(
239
+ client: ReclaimOldClient,
240
+ args: Args,
241
+ query: SessionLogsQuery,
242
+ saveTo: string,
243
+ ) {
244
+ const maxEntries = Math.max(args.maxEntries ?? DEFAULT_MAX_ENTRIES, 1)
245
+ const all: SessionLogRow[] = []
246
+ const failedRegions = new Set<string>()
247
+ let total: number | undefined
248
+ let offset = 0
249
+
250
+ while(all.length < maxEntries) {
251
+ const res = await client.getSessionLogs(args.sessionId, {
252
+ ...query,
253
+ limit: Math.min(MAX_PAGE, maxEntries - all.length),
254
+ offset,
255
+ // Only the first page pays for the count; it does not move.
256
+ includeCount: offset === 0,
257
+ })
258
+ for(const region of res.failedRegions ?? []) {
259
+ failedRegions.add(region)
260
+ }
261
+
262
+ if(offset === 0) {
263
+ total = res.totalCount
264
+ }
265
+
266
+ all.push(...res.data)
267
+ offset += res.data.length
268
+ // A short page means the match set is exhausted.
269
+ if(!res.data.length || res.data.length < MAX_PAGE) {
270
+ break
271
+ }
272
+
273
+ if(total !== undefined && offset >= total) {
274
+ break
275
+ }
276
+ }
277
+
278
+ all.reverse()
279
+ const entries = args.eventsOnly ? all.filter(hasEvent) : all
280
+
281
+ const path = isAbsolute(saveTo)
282
+ ? saveTo
283
+ : join(findProjectRoot(process.cwd()), saveTo)
284
+ mkdirSync(dirname(path), { recursive: true })
285
+ writeFileSync(path, entries.map(e => JSON.stringify(e)).join('\n') + '\n')
286
+
287
+ return {
288
+ ...summarize(args, [...failedRegions], entries, total),
289
+ file: {
290
+ path,
291
+ format: 'ndjson',
292
+ entries: entries.length,
293
+ order: 'oldest first',
294
+ note: 'Each line holds one raw log entry with all of its columns: '
295
+ + 'timestamp, log_level, event_type, log_type, log_line, '
296
+ + 'session_id, app_id, provider_id, device_id, source, and '
297
+ + 'metadata.',
298
+ truncated: entries.length >= maxEntries
299
+ ? `Stopped at maxEntries=${maxEntries}. Narrow the filters, or `
300
+ + 'raise the limit.'
301
+ : undefined,
302
+ },
303
+ }
304
+ }
305
+
306
+ /** The header both modes share: what was asked for, what came back, and the
307
+ * event-type histogram — usually enough on its own to see where a session
308
+ * died. */
309
+ function summarize(
310
+ args: Args,
311
+ failedRegions: string[] | undefined,
312
+ entries: SessionLogRow[],
313
+ total: number | undefined,
314
+ ) {
315
+ const events: Record<string, number> = {}
316
+ for(const entry of entries) {
317
+ if(entry.event_type) {
318
+ events[entry.event_type] = (events[entry.event_type] ?? 0) + 1
319
+ }
320
+ }
321
+
322
+ return {
323
+ sessionId: args.sessionId,
324
+ context: contextOf(entries),
325
+ totalMatching: total,
326
+ span: entries.length
327
+ ? {
328
+ from: entries[0].timestamp,
329
+ to: entries[entries.length - 1].timestamp,
330
+ }
331
+ : undefined,
332
+ eventTypes: Object.keys(events).length ? events : undefined,
333
+ ...(failedRegions?.length
334
+ ? {
335
+ warning: 'The log-stream query failed in region '
336
+ + `${failedRegions.join(', ')}, so this result is incomplete.`,
337
+ }
338
+ : {}),
339
+ ...(entries.length
340
+ ? {}
341
+ : {
342
+ hint: 'No entries matched. Check these causes in order: the '
343
+ + 'search window defaults to the last 3 days, so pass startTime '
344
+ + 'and endTime for an older session; the backend deletes entries '
345
+ + 'after 30 days; the backend picks which regions to query from '
346
+ + 'the analytics table, so a session that never recorded an '
347
+ + 'analytics event reads as empty here even when entries exist; '
348
+ + 'or the filters are too narrow. Cross-check with '
349
+ + 'session_analytics_logs.',
350
+ }),
351
+ }
352
+ }
353
+
354
+ /** Fields that are identical on every entry, hoisted out so they are not
355
+ * repeated hundreds of times. */
356
+ function contextOf(entries: SessionLogRow[]) {
357
+ const uniq = (pick: (entry: SessionLogRow) => string | undefined) => {
358
+ const set = new Set(entries.map(pick).filter(Boolean) as string[])
359
+ return [...set]
360
+ }
361
+
362
+ const one = (values: string[]) => (values.length === 1 ? values[0] : values)
363
+ return entries.length
364
+ ? {
365
+ appId: one(uniq(e => e.app_id)),
366
+ providerId: one(uniq(e => e.provider_id)),
367
+ deviceId: one(uniq(e => e.device_id)),
368
+ source: one(uniq(e => e.source)),
369
+ }
370
+ : undefined
371
+ }
372
+
373
+ function compact(entries: SessionLogRow[]): CompactEntry[] {
374
+ const devices = new Set(entries.map(e => e.device_id))
375
+ const sources = new Set(entries.map(e => e.source))
376
+ return entries.map(e => ({
377
+ ts: e.timestamp,
378
+ level: e.log_level,
379
+ ...(e.event_type ? { event: e.event_type } : {}),
380
+ logger: e.log_type,
381
+ line: e.log_line,
382
+ // Only worth repeating per entry once the page actually mixes them.
383
+ ...(devices.size > 1 ? { device: e.device_id } : {}),
384
+ ...(sources.size > 1 ? { source: e.source } : {}),
385
+ ...(e.metadata && e.metadata !== '{}' ? { metadata: e.metadata } : {}),
386
+ }))
387
+ }
388
+
389
+ function hasEvent(entry: SessionLogRow): boolean {
390
+ return !!entry.event_type
391
+ }
392
+
393
+ function clampLimit(limit: number): number {
394
+ return Math.min(Math.max(Math.trunc(limit), 1), MAX_PAGE)
395
+ }