@zintrust/trace 0.4.95 → 0.5.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.
@@ -1,5 +1,6 @@
1
+ import { generateUuid } from '@zintrust/core';
1
2
  import { TraceContext } from '../context.js';
2
- import { EntryType } from '../types.js';
3
+ import { EntryType, } from '../types.js';
3
4
  import { AuthTag } from '../utils/authTag.js';
4
5
  import { redactHeaders, redactUnknown } from '../utils/redact.js';
5
6
  import { RequestFilter } from '../utils/requestFilter.js';
@@ -7,35 +8,127 @@ let _storage = null;
7
8
  let _redactHeaderNames = [];
8
9
  let _redactBodyFields = [];
9
10
  let _ignoreRoutes = [];
10
- const emit = ({ method, url, requestHeaders, responseStatus, duration, requestBody, responseHeaders, responseBody, error, }) => {
11
+ let _clientRequestWatcher;
12
+ const isObjectValue = (value) => {
13
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ };
15
+ const resolveSource = (value) => {
16
+ if (typeof value !== 'string')
17
+ return undefined;
18
+ const normalized = value.trim().toLowerCase();
19
+ return normalized === '' ? undefined : normalized;
20
+ };
21
+ const resolveSourceRule = (source) => {
22
+ if (source === undefined)
23
+ return undefined;
24
+ return _clientRequestWatcher?.sources?.[source];
25
+ };
26
+ const shouldCaptureField = (field, sourceRule) => {
27
+ const scoped = sourceRule?.[field];
28
+ if (typeof scoped === 'boolean')
29
+ return scoped;
30
+ const global = _clientRequestWatcher?.[field];
31
+ if (typeof global === 'boolean')
32
+ return global;
33
+ return true;
34
+ };
35
+ const buildRequestHeaders = (requestHeaders, sourceRule) => {
36
+ return shouldCaptureField('requestHeaders', sourceRule)
37
+ ? { requestHeaders: redactHeaders(requestHeaders, _redactHeaderNames) }
38
+ : { requestHeaders: {} };
39
+ };
40
+ const buildRequestBody = (requestBody, sourceRule) => {
41
+ if (requestBody === undefined)
42
+ return {};
43
+ if (!shouldCaptureField('requestBody', sourceRule))
44
+ return {};
45
+ return { requestBody: redactUnknown(requestBody, _redactBodyFields) };
46
+ };
47
+ const buildResponseHeaders = (responseHeaders, sourceRule) => {
48
+ if (responseHeaders === undefined)
49
+ return {};
50
+ if (!shouldCaptureField('responseHeaders', sourceRule))
51
+ return {};
52
+ return { responseHeaders: redactHeaders(responseHeaders, _redactHeaderNames) };
53
+ };
54
+ const buildResponseBody = (responseBody, sourceRule) => {
55
+ if (responseBody === undefined)
56
+ return {};
57
+ if (!shouldCaptureField('responseBody', sourceRule))
58
+ return {};
59
+ return { responseBody: redactUnknown(responseBody, _redactBodyFields) };
60
+ };
61
+ const applySource = (content, normalizedSource) => {
62
+ if (normalizedSource !== undefined) {
63
+ content.source = normalizedSource;
64
+ }
65
+ };
66
+ const applyResponseStatus = (content, responseStatus) => {
67
+ if (responseStatus !== undefined) {
68
+ content.responseStatus = responseStatus;
69
+ }
70
+ };
71
+ const applyError = (content, error) => {
72
+ if (typeof error === 'string' && error !== '') {
73
+ content.error = error;
74
+ }
75
+ };
76
+ const mergePartialContent = (content, partial) => {
77
+ Object.assign(content, partial);
78
+ };
79
+ const buildClientRequestContent = (input, sourceRule, normalizedSource) => {
80
+ const content = {
81
+ method: input.method.toUpperCase(),
82
+ url: input.url,
83
+ requestHeaders: {},
84
+ duration: input.duration,
85
+ hostname: TraceContext.getHostname(),
86
+ };
87
+ applySource(content, normalizedSource);
88
+ mergePartialContent(content, buildRequestHeaders(input.requestHeaders, sourceRule));
89
+ mergePartialContent(content, buildRequestBody(input.requestBody, sourceRule));
90
+ applyResponseStatus(content, input.responseStatus);
91
+ mergePartialContent(content, buildResponseHeaders(input.responseHeaders, sourceRule));
92
+ mergePartialContent(content, buildResponseBody(input.responseBody, sourceRule));
93
+ applyError(content, input.error);
94
+ return content;
95
+ };
96
+ const isWatcherEnabled = (value) => {
97
+ if (value === false)
98
+ return false;
99
+ if (isObjectValue(value) && value.enabled === false)
100
+ return false;
101
+ return true;
102
+ };
103
+ const emit = ({ source, method, url, requestHeaders, responseStatus, duration, requestBody, responseHeaders, responseBody, error, }) => {
11
104
  if (!_storage)
12
105
  return;
13
106
  if (RequestFilter.shouldIgnoreCurrentRequest(_ignoreRoutes))
14
107
  return;
108
+ const normalizedSource = resolveSource(source);
109
+ const sourceRule = resolveSourceRule(normalizedSource);
110
+ if (sourceRule?.enabled === false)
111
+ return;
15
112
  const tags = AuthTag.append([method.toUpperCase()]);
16
113
  if ((responseStatus ?? 0) >= 400 || error)
17
114
  tags.push('failed');
18
- const content = {
19
- method: method.toUpperCase(),
115
+ if (normalizedSource !== undefined)
116
+ tags.push(normalizedSource);
117
+ const content = buildClientRequestContent({
118
+ source,
119
+ method,
20
120
  url,
21
- requestHeaders: redactHeaders(requestHeaders, _redactHeaderNames),
22
- ...(requestBody === undefined
23
- ? {}
24
- : { requestBody: redactUnknown(requestBody, _redactBodyFields) }),
25
- ...(responseStatus === undefined ? {} : { responseStatus }),
26
- ...(responseHeaders === undefined
27
- ? {}
28
- : { responseHeaders: redactHeaders(responseHeaders, _redactHeaderNames) }),
29
- ...(responseBody === undefined
30
- ? {}
31
- : { responseBody: redactUnknown(responseBody, _redactBodyFields) }),
32
- ...(typeof error === 'string' && error !== '' ? { error } : {}),
121
+ requestHeaders,
122
+ responseStatus,
33
123
  duration,
34
- hostname: TraceContext.getHostname(),
35
- };
124
+ requestBody,
125
+ responseHeaders,
126
+ responseBody,
127
+ error,
128
+ }, sourceRule, normalizedSource);
36
129
  _storage
37
130
  .writeEntry({
38
- uuid: crypto.randomUUID(),
131
+ uuid: generateUuid(),
39
132
  batchId: TraceContext.getBatchId(),
40
133
  type: EntryType.CLIENT_REQUEST,
41
134
  content,
@@ -48,14 +141,19 @@ const emit = ({ method, url, requestHeaders, responseStatus, duration, requestBo
48
141
  export const HttpClientWatcher = Object.freeze({
49
142
  emit,
50
143
  register({ storage, config }) {
51
- if (config.watchers.clientRequest === false)
144
+ if (!isWatcherEnabled(config.watchers.clientRequest))
52
145
  return () => undefined;
53
146
  _storage = storage;
147
+ _clientRequestWatcher =
148
+ typeof config.watchers.clientRequest === 'object' && config.watchers.clientRequest !== null
149
+ ? config.watchers.clientRequest
150
+ : undefined;
54
151
  _redactHeaderNames = [...(config.redaction?.keys ?? []), ...(config.redaction?.headers ?? [])];
55
152
  _redactBodyFields = [...(config.redaction?.keys ?? []), ...(config.redaction?.body ?? [])];
56
153
  _ignoreRoutes = config.ignoreRoutes;
57
154
  return () => {
58
155
  _storage = null;
156
+ _clientRequestWatcher = undefined;
59
157
  _redactBodyFields = [];
60
158
  _ignoreRoutes = [];
61
159
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zintrust/trace",
3
- "version": "0.4.95",
3
+ "version": "0.5.0",
4
4
  "description": "Trace assistant for ZinTrust: logs requests, queries, exceptions, jobs, and more.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  "node": ">=20.0.0"
41
41
  },
42
42
  "peerDependencies": {
43
- "@zintrust/core": "^0.4.94"
43
+ "@zintrust/core": "^0.4.101"
44
44
  },
45
45
  "publishConfig": {
46
46
  "access": "public"
package/src/config.ts CHANGED
@@ -3,6 +3,8 @@
3
3
  */
4
4
  import type {
5
5
  ITraceConfig,
6
+ TraceClientRequestCaptureRule,
7
+ TraceClientRequestWatcherToggle,
6
8
  TraceConfigOverrides,
7
9
  TraceFilterRule,
8
10
  TraceRequestWatcherConfig,
@@ -25,19 +27,44 @@ const isObjectValue = (value: unknown): value is Record<string, unknown> => {
25
27
  return typeof value === 'object' && value !== null && !Array.isArray(value);
26
28
  };
27
29
 
30
+ const resolveEnabled = (
31
+ base?: TraceFilterRule,
32
+ override?: TraceFilterRule
33
+ ): boolean | undefined => {
34
+ return override?.enabled ?? base?.enabled;
35
+ };
36
+
37
+ const hasMergedRuleValues = (
38
+ include: string[],
39
+ exclude: string[],
40
+ enabled: boolean | undefined
41
+ ): boolean => {
42
+ return include.length > 0 || exclude.length > 0 || enabled !== undefined;
43
+ };
44
+
45
+ const buildFilterRule = (input: {
46
+ include: string[];
47
+ exclude: string[];
48
+ enabled: boolean | undefined;
49
+ }): TraceFilterRule => {
50
+ return Object.freeze({
51
+ ...(input.enabled === undefined ? {} : { enabled: input.enabled }),
52
+ ...(input.include.length > 0 ? { include: input.include } : {}),
53
+ ...(input.exclude.length > 0 ? { exclude: input.exclude } : {}),
54
+ });
55
+ };
56
+
28
57
  const mergeFilterRule = (
29
58
  base?: TraceFilterRule,
30
59
  override?: TraceFilterRule
31
60
  ): TraceFilterRule | undefined => {
32
61
  const include = mergeStringLists(base?.include ?? [], override?.include);
33
62
  const exclude = mergeStringLists(base?.exclude ?? [], override?.exclude);
63
+ const enabled = resolveEnabled(base, override);
34
64
 
35
- if (include.length === 0 && exclude.length === 0) return undefined;
65
+ if (!hasMergedRuleValues(include, exclude, enabled)) return undefined;
36
66
 
37
- return Object.freeze({
38
- ...(include.length > 0 ? { include } : {}),
39
- ...(exclude.length > 0 ? { exclude } : {}),
40
- });
67
+ return buildFilterRule({ include, exclude, enabled });
41
68
  };
42
69
 
43
70
  const mergeWatcherToggle = (
@@ -51,6 +78,109 @@ const mergeWatcherToggle = (
51
78
  return mergeFilterRule(baseRule, override);
52
79
  };
53
80
 
81
+ type ClientRequestCaptureFlags = Pick<
82
+ TraceClientRequestCaptureRule,
83
+ 'requestHeaders' | 'requestBody' | 'responseHeaders' | 'responseBody'
84
+ >;
85
+
86
+ const resolveClientRequestCaptureFlags = (
87
+ base?: TraceClientRequestCaptureRule,
88
+ override?: TraceClientRequestCaptureRule
89
+ ): ClientRequestCaptureFlags => {
90
+ return {
91
+ requestHeaders: override?.requestHeaders ?? base?.requestHeaders,
92
+ requestBody: override?.requestBody ?? base?.requestBody,
93
+ responseHeaders: override?.responseHeaders ?? base?.responseHeaders,
94
+ responseBody: override?.responseBody ?? base?.responseBody,
95
+ };
96
+ };
97
+
98
+ const hasClientRequestCaptureFlags = (flags: ClientRequestCaptureFlags): boolean => {
99
+ return Object.values(flags).some((value) => value !== undefined);
100
+ };
101
+
102
+ const buildClientRequestCaptureRule = (
103
+ mergedRule: TraceFilterRule | undefined,
104
+ flags: ClientRequestCaptureFlags
105
+ ): TraceClientRequestCaptureRule => {
106
+ const baseRule = mergedRule ? { ...mergedRule } : {};
107
+
108
+ return Object.freeze({
109
+ ...baseRule,
110
+ ...(flags.requestHeaders === undefined ? {} : { requestHeaders: flags.requestHeaders }),
111
+ ...(flags.requestBody === undefined ? {} : { requestBody: flags.requestBody }),
112
+ ...(flags.responseHeaders === undefined ? {} : { responseHeaders: flags.responseHeaders }),
113
+ ...(flags.responseBody === undefined ? {} : { responseBody: flags.responseBody }),
114
+ });
115
+ };
116
+
117
+ const mergeClientRequestCaptureRule = (
118
+ base?: TraceClientRequestCaptureRule,
119
+ override?: TraceClientRequestCaptureRule
120
+ ): TraceClientRequestCaptureRule | undefined => {
121
+ const mergedRule = mergeFilterRule(base, override);
122
+ const flags = resolveClientRequestCaptureFlags(base, override);
123
+
124
+ if (mergedRule === undefined && !hasClientRequestCaptureFlags(flags)) {
125
+ return undefined;
126
+ }
127
+
128
+ return buildClientRequestCaptureRule(mergedRule, flags);
129
+ };
130
+
131
+ const collectClientRequestSourceKeys = (
132
+ base?: TraceClientRequestWatcherToggle,
133
+ override?: Exclude<TraceClientRequestWatcherToggle, boolean>
134
+ ): string[] => {
135
+ const overrideSources = override?.sources ?? {};
136
+ const sourceKeys = new Set<string>([
137
+ ...Object.keys(isObjectValue(base) ? (base.sources ?? {}) : {}),
138
+ ...Object.keys(overrideSources),
139
+ ]);
140
+
141
+ return [...sourceKeys];
142
+ };
143
+
144
+ const mergeClientRequestSources = (
145
+ base?: TraceClientRequestWatcherToggle,
146
+ override?: Exclude<TraceClientRequestWatcherToggle, boolean>
147
+ ): Record<string, TraceClientRequestCaptureRule> | undefined => {
148
+ if (override === undefined) return undefined;
149
+
150
+ const sources: Record<string, TraceClientRequestCaptureRule> = {};
151
+
152
+ for (const key of collectClientRequestSourceKeys(base, override)) {
153
+ const baseSources = isObjectValue(base) ? base.sources : undefined;
154
+ const sourceRule = mergeClientRequestCaptureRule(baseSources?.[key], override.sources?.[key]);
155
+ if (sourceRule !== undefined) {
156
+ sources[key] = sourceRule;
157
+ }
158
+ }
159
+
160
+ return Object.keys(sources).length === 0 ? undefined : sources;
161
+ };
162
+
163
+ const mergeClientRequestWatcherToggle = (
164
+ base?: TraceClientRequestWatcherToggle,
165
+ override?: TraceClientRequestWatcherToggle
166
+ ): TraceClientRequestWatcherToggle | undefined => {
167
+ if (override === undefined) return base;
168
+ if (override === false || override === true) return override;
169
+
170
+ const baseConfig = isObjectValue(base) ? base : undefined;
171
+ const merged = mergeClientRequestCaptureRule(baseConfig, override) ?? {};
172
+ const sources = mergeClientRequestSources(base, override);
173
+
174
+ if (sources === undefined) {
175
+ return merged;
176
+ }
177
+
178
+ return Object.freeze({
179
+ ...merged,
180
+ sources,
181
+ });
182
+ };
183
+
54
184
  const REQUEST_METHOD_KEYS = ['all', 'get', 'post', 'put', 'patch', 'delete'] as const;
55
185
 
56
186
  const mergeRequestWatcherToggle = (
@@ -99,7 +229,7 @@ const mergeWatchers = (
99
229
  batch: mergeWatcherToggle(base.batch, override.batch),
100
230
  dump: mergeWatcherToggle(base.dump, override.dump),
101
231
  view: mergeWatcherToggle(base.view, override.view),
102
- clientRequest: mergeWatcherToggle(base.clientRequest, override.clientRequest),
232
+ clientRequest: mergeClientRequestWatcherToggle(base.clientRequest, override.clientRequest),
103
233
  };
104
234
  };
105
235
 
@@ -3,7 +3,7 @@
3
3
  * No auth in this layer — caller mounts middleware as needed.
4
4
  */
5
5
  import type { IRequest, IResponse } from '@zintrust/core';
6
- import type { EntryTypeValue, ITraceStorage } from '../types';
6
+ import type { EntryTypeValue, ITraceEntry, ITraceStorage } from '../types';
7
7
 
8
8
  // ---------------------------------------------------------------------------
9
9
  // Storage holder (set once from routes.ts)
@@ -57,6 +57,128 @@ const getNumericQueryParam = (req: IRequest, key: string): number | undefined =>
57
57
  return undefined;
58
58
  };
59
59
 
60
+ const DEFAULT_PER_PAGE = 50;
61
+ const MAX_PER_PAGE = 100;
62
+ const DEFAULT_REQUEST_PER_PAGE = 25;
63
+ const MAX_REQUEST_PER_PAGE = 50;
64
+ const SUMMARY_TEXT_LIMIT = 280;
65
+ const SUMMARY_ARRAY_LIMIT = 10;
66
+
67
+ type CompactTraceEntry = ITraceEntry<Record<string, unknown>> & {
68
+ hasDetails: true;
69
+ contentBytes?: number;
70
+ };
71
+
72
+ const truncateText = (value: string, limit = SUMMARY_TEXT_LIMIT): string =>
73
+ value.length <= limit ? value : `${value.slice(0, Math.max(0, limit - 3))}...`;
74
+
75
+ const compactValue = (value: unknown): unknown => {
76
+ if (typeof value === 'string') {
77
+ return truncateText(value);
78
+ }
79
+
80
+ if (
81
+ typeof value === 'number' ||
82
+ typeof value === 'boolean' ||
83
+ value === null ||
84
+ value === undefined
85
+ ) {
86
+ return value;
87
+ }
88
+
89
+ if (Array.isArray(value)) {
90
+ return value.slice(0, SUMMARY_ARRAY_LIMIT).map((item) => {
91
+ if (typeof item === 'string') {
92
+ return truncateText(item);
93
+ }
94
+
95
+ if (typeof item === 'number' || typeof item === 'boolean' || item === null) {
96
+ return item;
97
+ }
98
+
99
+ return '[complex]';
100
+ });
101
+ }
102
+
103
+ return undefined;
104
+ };
105
+
106
+ const pickCompactContent = (content: unknown, keys: readonly string[]): Record<string, unknown> => {
107
+ if (typeof content !== 'object' || content === null || Array.isArray(content)) {
108
+ return {};
109
+ }
110
+
111
+ const source = content as Record<string, unknown>;
112
+ const compact: Record<string, unknown> = {};
113
+
114
+ for (const key of keys) {
115
+ const value = compactValue(source[key]);
116
+ if (value !== undefined) {
117
+ compact[key] = value;
118
+ }
119
+ }
120
+
121
+ return compact;
122
+ };
123
+
124
+ const COMPACT_ENTRY_KEYS: Record<EntryTypeValue, readonly string[]> = {
125
+ request: [
126
+ 'method',
127
+ 'uri',
128
+ 'responseStatus',
129
+ 'duration',
130
+ 'memory',
131
+ 'middleware',
132
+ 'hostname',
133
+ 'userId',
134
+ ],
135
+ query: ['connection', 'sql', 'time', 'duration', 'slow', 'hash', 'hostname'],
136
+ exception: ['class', 'file', 'line', 'message', 'occurrences', 'hostname', 'userId'],
137
+ log: ['level', 'message', 'hostname'],
138
+ job: ['status', 'connection', 'queue', 'name', 'tries', 'timeout', 'hostname'],
139
+ cache: ['operation', 'key', 'hit', 'store', 'payloadLogged', 'ttl', 'duration', 'hostname'],
140
+ schedule: ['name', 'expression', 'status', 'duration', 'hostname'],
141
+ mail: ['to', 'subject', 'template', 'hostname'],
142
+ auth: ['event', 'userId', 'hostname'],
143
+ event: ['name', 'listenerCount', 'hostname'],
144
+ model: ['action', 'model', 'id', 'hostname'],
145
+ notification: ['channels', 'notifiable', 'notification', 'message', 'hostname'],
146
+ redis: ['command', 'duration', 'hostname'],
147
+ gate: ['ability', 'result', 'userId', 'subject', 'hostname'],
148
+ middleware: ['name', 'event', 'duration', 'hostname'],
149
+ command: ['name', 'exitCode', 'duration', 'hostname'],
150
+ batch: ['name', 'total', 'processed', 'failed', 'status', 'hostname'],
151
+ dump: ['file', 'line', 'hostname'],
152
+ view: ['template', 'duration', 'hostname'],
153
+ client_request: ['source', 'method', 'url', 'responseStatus', 'error', 'duration', 'hostname'],
154
+ };
155
+
156
+ const compactEntryContent = (entry: ITraceEntry): Record<string, unknown> =>
157
+ pickCompactContent(entry.content, COMPACT_ENTRY_KEYS[entry.type]);
158
+
159
+ const estimateContentBytes = (content: unknown): number | undefined => {
160
+ try {
161
+ return new TextEncoder().encode(JSON.stringify(content)).length;
162
+ } catch {
163
+ return undefined;
164
+ }
165
+ };
166
+
167
+ const compactListEntry = (entry: ITraceEntry): CompactTraceEntry => ({
168
+ ...entry,
169
+ content: compactEntryContent(entry),
170
+ hasDetails: true,
171
+ contentBytes: estimateContentBytes(entry.content),
172
+ });
173
+
174
+ const resolvePerPage = (req: IRequest, type?: EntryTypeValue): number => {
175
+ const isRequestList = type === 'request';
176
+ const fallback = isRequestList ? DEFAULT_REQUEST_PER_PAGE : DEFAULT_PER_PAGE;
177
+ const limit = isRequestList ? MAX_REQUEST_PER_PAGE : MAX_PER_PAGE;
178
+
179
+ return Math.max(1, Math.min(qpInt(req, 'perPage', fallback), limit));
180
+ };
181
+
60
182
  // ---------------------------------------------------------------------------
61
183
  // Entry handlers
62
184
  // ---------------------------------------------------------------------------
@@ -64,18 +186,25 @@ const getNumericQueryParam = (req: IRequest, key: string): number | undefined =>
64
186
  export async function listEntries(req: IRequest, res: IResponse): Promise<void> {
65
187
  const storage = getStorage(res);
66
188
  if (storage !== null) {
189
+ const type = qp(req, 'type') as EntryTypeValue | undefined;
67
190
  const opts = {
68
- type: qp(req, 'type') as EntryTypeValue | undefined,
191
+ type,
69
192
  tag: qp(req, 'tag'),
70
193
  batchId: qp(req, 'batchId'),
71
194
  from: getNumericQueryParam(req, 'from'),
72
195
  to: getNumericQueryParam(req, 'to'),
73
- page: qpInt(req, 'page', 1),
74
- perPage: Math.min(qpInt(req, 'perPage', 50), 200),
196
+ page: Math.max(1, qpInt(req, 'page', 1)),
197
+ perPage: resolvePerPage(req, type),
75
198
  };
76
199
  try {
77
200
  const result = await storage.queryEntries(opts);
78
- res.json({ ok: true, ...result, page: opts.page, perPage: opts.perPage });
201
+ res.json({
202
+ ok: true,
203
+ data: result.data.map(compactListEntry),
204
+ total: result.total,
205
+ page: opts.page,
206
+ perPage: opts.perPage,
207
+ });
79
208
  } catch (err) {
80
209
  res.setStatus(500).json({ error: (err as Error).message });
81
210
  }
@@ -38,6 +38,7 @@ const SUN_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke
38
38
  const MOON_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path></svg>`;
39
39
 
40
40
  const COPY_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
41
+ const DISCLOSURE_ICON = `<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4l6 6-6 6"></path></svg>`;
41
42
 
42
43
  const JSON_HIGHLIGHT_PATTERN = String.raw`("(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*")(?=\s*:)|(\s*:)|("(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*")|\btrue\b|\bfalse\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?`;
43
44
  const SQL_HIGHLIGHT_PATTERN = String.raw`(\/\*[\s\S]*?\*\/|--.*$|'(?:''|[^'])*'|\x60[^\x60]+\x60|\b(?:select|from|where|insert|into|values|update|delete|join|left|right|inner|outer|on|and|or|limit|order|by|group|having|as|distinct|null|is|in|like|set|case|when|then|else|end|returning|union|all)\b|-?\d+(?:\.\d+)?)`;
@@ -137,6 +138,7 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
137
138
  const SUN_ICON = __TRACE_SUN_ICON__;
138
139
  const MOON_ICON = __TRACE_MOON_ICON__;
139
140
  const COPY_ICON = __TRACE_COPY_ICON__;
141
+ const DISCLOSURE_ICON = __TRACE_DISCLOSURE_ICON__;
140
142
  const JSON_HIGHLIGHT_PATTERN = new RegExp(__TRACE_JSON_REGEX__, 'g');
141
143
  const SQL_HIGHLIGHT_PATTERN = new RegExp(__TRACE_SQL_REGEX__, 'gim');
142
144
  const ENTRY_TYPES = ['request','query','exception','log','job','cache','schedule','mail','auth','event','model','notification','redis','gate','middleware','command','batch','dump','view','client_request'];
@@ -651,6 +653,7 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
651
653
  return [
652
654
  '<details class="trace-item trace-disclosure"' + (isInitiallyOpen ? ' open' : '') + '>',
653
655
  '<summary class="trace-item-head trace-summary">',
656
+ '<span class="trace-summary-icon">' + DISCLOSURE_ICON + '</span>',
654
657
  '<span class="trace-summary-main">',
655
658
  '<span><span class="' + typeClass(entry) + '">' + escapeHtml(entryTypeLabel(entry)) + '</span></span>',
656
659
  '<span class="trace-summary-copy">' + entrySummaryInlineHtml(entry) + '</span>',
@@ -1083,6 +1086,7 @@ const buildDashboardHtml = (basePath: string, projectName?: string): string => {
1083
1086
  .replace('__TRACE_SUN_ICON__', JSON.stringify(SUN_ICON))
1084
1087
  .replace('__TRACE_MOON_ICON__', JSON.stringify(MOON_ICON))
1085
1088
  .replace('__TRACE_COPY_ICON__', JSON.stringify(COPY_ICON))
1089
+ .replace('__TRACE_DISCLOSURE_ICON__', JSON.stringify(DISCLOSURE_ICON))
1086
1090
  .replace('__TRACE_JSON_REGEX__', JSON.stringify(JSON_HIGHLIGHT_PATTERN))
1087
1091
  .replace('__TRACE_SQL_REGEX__', JSON.stringify(SQL_HIGHLIGHT_PATTERN))
1088
1092
  .replace('__TRACE_BASE_PATH_LABEL__', basePath)