posthog-node 4.4.0 → 4.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/index.ts +1 -0
  3. package/lib/index.cjs.js +911 -7
  4. package/lib/index.cjs.js.map +1 -1
  5. package/lib/index.d.ts +44 -3
  6. package/lib/index.esm.js +911 -7
  7. package/lib/index.esm.js.map +1 -1
  8. package/lib/posthog-core/src/index.d.ts +6 -0
  9. package/lib/posthog-core/src/types.d.ts +1 -0
  10. package/lib/posthog-core/src/utils.d.ts +2 -0
  11. package/lib/posthog-node/index.d.ts +1 -0
  12. package/lib/posthog-node/src/error-tracking.d.ts +12 -0
  13. package/lib/posthog-node/src/extensions/error-tracking/autocapture.d.ts +3 -0
  14. package/lib/posthog-node/src/extensions/error-tracking/context-lines.d.ts +4 -0
  15. package/lib/posthog-node/src/extensions/error-tracking/error-conversion.d.ts +5 -0
  16. package/lib/posthog-node/src/extensions/error-tracking/reduceable-cache.d.ts +12 -0
  17. package/lib/posthog-node/src/extensions/error-tracking/stack-trace.d.ts +15 -0
  18. package/lib/posthog-node/src/extensions/error-tracking/type-checking.d.ts +7 -0
  19. package/lib/posthog-node/src/extensions/error-tracking/types.d.ts +57 -0
  20. package/lib/posthog-node/src/extensions/express.d.ts +17 -0
  21. package/lib/posthog-node/src/extensions/sentry-integration.d.ts +1 -2
  22. package/lib/posthog-node/src/fetch.d.ts +1 -2
  23. package/lib/posthog-node/src/posthog-node.d.ts +5 -0
  24. package/package.json +1 -1
  25. package/src/error-tracking.ts +66 -0
  26. package/src/extensions/error-tracking/autocapture.ts +62 -0
  27. package/src/extensions/error-tracking/context-lines.ts +389 -0
  28. package/src/extensions/error-tracking/error-conversion.ts +250 -0
  29. package/src/extensions/error-tracking/reduceable-cache.ts +36 -0
  30. package/src/extensions/error-tracking/stack-trace.ts +269 -0
  31. package/src/extensions/error-tracking/type-checking.ts +37 -0
  32. package/src/extensions/error-tracking/types.ts +62 -0
  33. package/src/extensions/express.ts +37 -0
  34. package/src/extensions/sentry-integration.ts +1 -3
  35. package/src/fetch.ts +3 -7
  36. package/src/posthog-node.ts +10 -0
@@ -0,0 +1,389 @@
1
+ import { createReadStream } from 'node:fs'
2
+ import { createInterface } from 'node:readline'
3
+ import { StackFrame } from './types'
4
+ import { ReduceableCache } from './reduceable-cache'
5
+
6
+ const LRU_FILE_CONTENTS_CACHE = new ReduceableCache<string, Record<number, string>>(25)
7
+ const LRU_FILE_CONTENTS_FS_READ_FAILED = new ReduceableCache<string, 1>(20)
8
+ const DEFAULT_LINES_OF_CONTEXT = 7
9
+ // Determines the upper bound of lineno/colno that we will attempt to read. Large colno values are likely to be
10
+ // minified code while large lineno values are likely to be bundled code.
11
+ // Exported for testing purposes.
12
+ export const MAX_CONTEXTLINES_COLNO: number = 1000
13
+ export const MAX_CONTEXTLINES_LINENO: number = 10000
14
+
15
+ type ReadlineRange = [start: number, end: number]
16
+
17
+ export async function addSourceContext(frames: StackFrame[]): Promise<StackFrame[]> {
18
+ // keep a lookup map of which files we've already enqueued to read,
19
+ // so we don't enqueue the same file multiple times which would cause multiple i/o reads
20
+ const filesToLines: Record<string, number[]> = {}
21
+
22
+ // Maps preserve insertion order, so we iterate in reverse, starting at the
23
+ // outermost frame and closer to where the exception has occurred (poor mans priority)
24
+ for (let i = frames.length - 1; i >= 0; i--) {
25
+ const frame: StackFrame | undefined = frames[i]
26
+ const filename = frame?.filename
27
+
28
+ if (
29
+ !frame ||
30
+ typeof filename !== 'string' ||
31
+ typeof frame.lineno !== 'number' ||
32
+ shouldSkipContextLinesForFile(filename) ||
33
+ shouldSkipContextLinesForFrame(frame)
34
+ ) {
35
+ continue
36
+ }
37
+
38
+ const filesToLinesOutput = filesToLines[filename]
39
+ if (!filesToLinesOutput) {
40
+ filesToLines[filename] = []
41
+ }
42
+ filesToLines[filename].push(frame.lineno)
43
+ }
44
+
45
+ const files = Object.keys(filesToLines)
46
+ if (files.length == 0) {
47
+ return frames
48
+ }
49
+
50
+ const readlinePromises: Promise<void>[] = []
51
+ for (const file of files) {
52
+ // If we failed to read this before, dont try reading it again.
53
+ if (LRU_FILE_CONTENTS_FS_READ_FAILED.get(file)) {
54
+ continue
55
+ }
56
+
57
+ const filesToLineRanges = filesToLines[file]
58
+ if (!filesToLineRanges) {
59
+ continue
60
+ }
61
+
62
+ // Sort ranges so that they are sorted by line increasing order and match how the file is read.
63
+ filesToLineRanges.sort((a, b) => a - b)
64
+ // Check if the contents are already in the cache and if we can avoid reading the file again.
65
+ const ranges = makeLineReaderRanges(filesToLineRanges)
66
+ if (ranges.every((r) => rangeExistsInContentCache(file, r))) {
67
+ continue
68
+ }
69
+
70
+ const cache = emplace(LRU_FILE_CONTENTS_CACHE, file, {})
71
+ readlinePromises.push(getContextLinesFromFile(file, ranges, cache))
72
+ }
73
+
74
+ // The promise rejections are caught in order to prevent them from short circuiting Promise.all
75
+ await Promise.all(readlinePromises).catch(() => {})
76
+
77
+ // Perform the same loop as above, but this time we can assume all files are in the cache
78
+ // and attempt to add source context to frames.
79
+ if (frames && frames.length > 0) {
80
+ addSourceContextToFrames(frames, LRU_FILE_CONTENTS_CACHE)
81
+ }
82
+
83
+ // Once we're finished processing an exception reduce the files held in the cache
84
+ // so that we don't indefinetly increase the size of this map
85
+ LRU_FILE_CONTENTS_CACHE.reduce()
86
+
87
+ return frames
88
+ }
89
+
90
+ /**
91
+ * Extracts lines from a file and stores them in a cache.
92
+ */
93
+ function getContextLinesFromFile(path: string, ranges: ReadlineRange[], output: Record<number, string>): Promise<void> {
94
+ return new Promise((resolve) => {
95
+ // It is important *not* to have any async code between createInterface and the 'line' event listener
96
+ // as it will cause the 'line' event to
97
+ // be emitted before the listener is attached.
98
+ const stream = createReadStream(path)
99
+ const lineReaded = createInterface({
100
+ input: stream,
101
+ })
102
+
103
+ // We need to explicitly destroy the stream to prevent memory leaks,
104
+ // removing the listeners on the readline interface is not enough.
105
+ // See: https://github.com/nodejs/node/issues/9002 and https://github.com/getsentry/sentry-javascript/issues/14892
106
+ function destroyStreamAndResolve(): void {
107
+ stream.destroy()
108
+ resolve()
109
+ }
110
+
111
+ // Init at zero and increment at the start of the loop because lines are 1 indexed.
112
+ let lineNumber = 0
113
+ let currentRangeIndex = 0
114
+ const range = ranges[currentRangeIndex]
115
+ if (range === undefined) {
116
+ // We should never reach this point, but if we do, we should resolve the promise to prevent it from hanging.
117
+ destroyStreamAndResolve()
118
+ return
119
+ }
120
+ let rangeStart = range[0]
121
+ let rangeEnd = range[1]
122
+
123
+ // We use this inside Promise.all, so we need to resolve the promise even if there is an error
124
+ // to prevent Promise.all from short circuiting the rest.
125
+ function onStreamError(): void {
126
+ // Mark file path as failed to read and prevent multiple read attempts.
127
+ LRU_FILE_CONTENTS_FS_READ_FAILED.set(path, 1)
128
+ lineReaded.close()
129
+ lineReaded.removeAllListeners()
130
+ destroyStreamAndResolve()
131
+ }
132
+
133
+ // We need to handle the error event to prevent the process from crashing in < Node 16
134
+ // https://github.com/nodejs/node/pull/31603
135
+ stream.on('error', onStreamError)
136
+ lineReaded.on('error', onStreamError)
137
+ lineReaded.on('close', destroyStreamAndResolve)
138
+
139
+ lineReaded.on('line', (line) => {
140
+ lineNumber++
141
+ if (lineNumber < rangeStart) {
142
+ return
143
+ }
144
+
145
+ // !Warning: This mutates the cache by storing the snipped line into the cache.
146
+ output[lineNumber] = snipLine(line, 0)
147
+
148
+ if (lineNumber >= rangeEnd) {
149
+ if (currentRangeIndex === ranges.length - 1) {
150
+ // We need to close the file stream and remove listeners, else the reader will continue to run our listener;
151
+ lineReaded.close()
152
+ lineReaded.removeAllListeners()
153
+ return
154
+ }
155
+ currentRangeIndex++
156
+ const range = ranges[currentRangeIndex]
157
+ if (range === undefined) {
158
+ // This should never happen as it means we have a bug in the context.
159
+ lineReaded.close()
160
+ lineReaded.removeAllListeners()
161
+ return
162
+ }
163
+ rangeStart = range[0]
164
+ rangeEnd = range[1]
165
+ }
166
+ })
167
+ })
168
+ }
169
+
170
+ /** Adds context lines to frames */
171
+ function addSourceContextToFrames(frames: StackFrame[], cache: ReduceableCache<string, Record<number, string>>): void {
172
+ for (const frame of frames) {
173
+ // Only add context if we have a filename and it hasn't already been added
174
+ if (frame.filename && frame.context_line === undefined && typeof frame.lineno === 'number') {
175
+ const contents = cache.get(frame.filename)
176
+ if (contents === undefined) {
177
+ continue
178
+ }
179
+
180
+ addContextToFrame(frame.lineno, frame, contents)
181
+ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Resolves context lines before and after the given line number and appends them to the frame;
187
+ */
188
+ function addContextToFrame(lineno: number, frame: StackFrame, contents: Record<number, string> | undefined): void {
189
+ // When there is no line number in the frame, attaching context is nonsensical and will even break grouping.
190
+ // We already check for lineno before calling this, but since StackFrame lineno is optional, we check it again.
191
+ if (frame.lineno === undefined || contents === undefined) {
192
+ return
193
+ }
194
+
195
+ frame.pre_context = []
196
+ for (let i = makeRangeStart(lineno); i < lineno; i++) {
197
+ // We always expect the start context as line numbers cannot be negative. If we dont find a line, then
198
+ // something went wrong somewhere. Clear the context and return without adding any linecontext.
199
+ const line = contents[i]
200
+ if (line === undefined) {
201
+ clearLineContext(frame)
202
+ return
203
+ }
204
+
205
+ frame.pre_context.push(line)
206
+ }
207
+
208
+ // We should always have the context line. If we dont, something went wrong, so we clear the context and return
209
+ // without adding any linecontext.
210
+ if (contents[lineno] === undefined) {
211
+ clearLineContext(frame)
212
+ return
213
+ }
214
+
215
+ frame.context_line = contents[lineno]
216
+
217
+ const end = makeRangeEnd(lineno)
218
+ frame.post_context = []
219
+ for (let i = lineno + 1; i <= end; i++) {
220
+ // Since we dont track when the file ends, we cant clear the context if we dont find a line as it could
221
+ // just be that we reached the end of the file.
222
+ const line = contents[i]
223
+ if (line === undefined) {
224
+ break
225
+ }
226
+ frame.post_context.push(line)
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Clears the context lines from a frame, used to reset a frame to its original state
232
+ * if we fail to resolve all context lines for it.
233
+ */
234
+ function clearLineContext(frame: StackFrame): void {
235
+ delete frame.pre_context
236
+ delete frame.context_line
237
+ delete frame.post_context
238
+ }
239
+
240
+ /**
241
+ * Determines if context lines should be skipped for a file.
242
+ * - .min.(mjs|cjs|js) files are and not useful since they dont point to the original source
243
+ * - node: prefixed modules are part of the runtime and cannot be resolved to a file
244
+ * - data: skip json, wasm and inline js https://nodejs.org/api/esm.html#data-imports
245
+ */
246
+ function shouldSkipContextLinesForFile(path: string): boolean {
247
+ // Test the most common prefix and extension first. These are the ones we
248
+ // are most likely to see in user applications and are the ones we can break out of first.
249
+ return (
250
+ path.startsWith('node:') ||
251
+ path.endsWith('.min.js') ||
252
+ path.endsWith('.min.cjs') ||
253
+ path.endsWith('.min.mjs') ||
254
+ path.startsWith('data:')
255
+ )
256
+ }
257
+
258
+ /**
259
+ * Determines if we should skip contextlines based off the max lineno and colno values.
260
+ */
261
+ function shouldSkipContextLinesForFrame(frame: StackFrame): boolean {
262
+ if (frame.lineno !== undefined && frame.lineno > MAX_CONTEXTLINES_LINENO) {
263
+ return true
264
+ }
265
+ if (frame.colno !== undefined && frame.colno > MAX_CONTEXTLINES_COLNO) {
266
+ return true
267
+ }
268
+ return false
269
+ }
270
+
271
+ /**
272
+ * Checks if we have all the contents that we need in the cache.
273
+ */
274
+ function rangeExistsInContentCache(file: string, range: ReadlineRange): boolean {
275
+ const contents = LRU_FILE_CONTENTS_CACHE.get(file)
276
+ if (contents === undefined) {
277
+ return false
278
+ }
279
+
280
+ for (let i = range[0]; i <= range[1]; i++) {
281
+ if (contents[i] === undefined) {
282
+ return false
283
+ }
284
+ }
285
+
286
+ return true
287
+ }
288
+
289
+ /**
290
+ * Creates contiguous ranges of lines to read from a file. In the case where context lines overlap,
291
+ * the ranges are merged to create a single range.
292
+ */
293
+ function makeLineReaderRanges(lines: number[]): ReadlineRange[] {
294
+ if (!lines.length) {
295
+ return []
296
+ }
297
+
298
+ let i = 0
299
+ const line = lines[0]
300
+
301
+ if (typeof line !== 'number') {
302
+ return []
303
+ }
304
+
305
+ let current = makeContextRange(line)
306
+ const out: ReadlineRange[] = []
307
+ while (true) {
308
+ if (i === lines.length - 1) {
309
+ out.push(current)
310
+ break
311
+ }
312
+
313
+ // If the next line falls into the current range, extend the current range to lineno + linecontext.
314
+ const next = lines[i + 1]
315
+ if (typeof next !== 'number') {
316
+ break
317
+ }
318
+ if (next <= current[1]) {
319
+ current[1] = next + DEFAULT_LINES_OF_CONTEXT
320
+ } else {
321
+ out.push(current)
322
+ current = makeContextRange(next)
323
+ }
324
+
325
+ i++
326
+ }
327
+
328
+ return out
329
+ }
330
+ // Determine start and end indices for context range (inclusive);
331
+ function makeContextRange(line: number): [start: number, end: number] {
332
+ return [makeRangeStart(line), makeRangeEnd(line)]
333
+ }
334
+ // Compute inclusive end context range
335
+ function makeRangeStart(line: number): number {
336
+ return Math.max(1, line - DEFAULT_LINES_OF_CONTEXT)
337
+ }
338
+ // Compute inclusive start context range
339
+ function makeRangeEnd(line: number): number {
340
+ return line + DEFAULT_LINES_OF_CONTEXT
341
+ }
342
+
343
+ /**
344
+ * Get or init map value
345
+ */
346
+ function emplace<T extends ReduceableCache<K, V>, K extends string, V>(map: T, key: K, contents: V): V {
347
+ const value = map.get(key)
348
+
349
+ if (value === undefined) {
350
+ map.set(key, contents)
351
+ return contents
352
+ }
353
+
354
+ return value
355
+ }
356
+
357
+ function snipLine(line: string, colno: number): string {
358
+ let newLine = line
359
+ const lineLength = newLine.length
360
+ if (lineLength <= 150) {
361
+ return newLine
362
+ }
363
+ if (colno > lineLength) {
364
+ colno = lineLength
365
+ }
366
+
367
+ let start = Math.max(colno - 60, 0)
368
+ if (start < 5) {
369
+ start = 0
370
+ }
371
+
372
+ let end = Math.min(start + 140, lineLength)
373
+ if (end > lineLength - 5) {
374
+ end = lineLength
375
+ }
376
+ if (end === lineLength) {
377
+ start = Math.max(end - 140, 0)
378
+ }
379
+
380
+ newLine = newLine.slice(start, end)
381
+ if (start > 0) {
382
+ newLine = `...${newLine}`
383
+ }
384
+ if (end < lineLength) {
385
+ newLine += '...'
386
+ }
387
+
388
+ return newLine
389
+ }
@@ -0,0 +1,250 @@
1
+ import { isError, isErrorEvent, isEvent, isPlainObject } from './type-checking'
2
+ import { ErrorProperties, EventHint, Exception, Mechanism, StackFrame, StackParser } from './types'
3
+ import { addSourceContext } from './context-lines'
4
+
5
+ /**
6
+ * based on the very wonderful MIT licensed Sentry SDK
7
+ */
8
+
9
+ export async function propertiesFromUnknownInput(
10
+ stackParser: StackParser,
11
+ input: unknown,
12
+ hint?: EventHint
13
+ ): Promise<ErrorProperties> {
14
+ const providedMechanism = hint && hint.mechanism
15
+ const mechanism = providedMechanism || {
16
+ handled: true,
17
+ type: 'generic',
18
+ }
19
+
20
+ const error = getError(mechanism, input, hint)
21
+ const exception = await exceptionFromError(stackParser, error)
22
+
23
+ exception.value = exception.value || ''
24
+ exception.type = exception.type || 'Error'
25
+ exception.mechanism = mechanism
26
+
27
+ const properties = { $exception_list: [exception] }
28
+
29
+ return properties
30
+ }
31
+
32
+ function getError(mechanism: Mechanism, exception: unknown, hint?: EventHint): Error {
33
+ if (isError(exception)) {
34
+ return exception
35
+ }
36
+
37
+ mechanism.synthetic = true
38
+
39
+ if (isPlainObject(exception)) {
40
+ const errorFromProp = getErrorPropertyFromObject(exception)
41
+ if (errorFromProp) {
42
+ return errorFromProp
43
+ }
44
+
45
+ const message = getMessageForObject(exception)
46
+ const ex = hint?.syntheticException || new Error(message)
47
+ ex.message = message
48
+
49
+ return ex
50
+ }
51
+
52
+ // This handles when someone does: `throw "something awesome";`
53
+ // We use synthesized Error here so we can extract a (rough) stack trace.
54
+ const ex = hint?.syntheticException || new Error(exception as string)
55
+ ex.message = `${exception}`
56
+
57
+ return ex
58
+ }
59
+
60
+ /** If a plain object has a property that is an `Error`, return this error. */
61
+ function getErrorPropertyFromObject(obj: Record<string, unknown>): Error | undefined {
62
+ for (const prop in obj) {
63
+ if (Object.prototype.hasOwnProperty.call(obj, prop)) {
64
+ const value = obj[prop]
65
+ if (value instanceof Error) {
66
+ return value
67
+ }
68
+ }
69
+ }
70
+
71
+ return undefined
72
+ }
73
+
74
+ function getMessageForObject(exception: Record<string, unknown>): string {
75
+ if ('name' in exception && typeof exception.name === 'string') {
76
+ let message = `'${exception.name}' captured as exception`
77
+
78
+ if ('message' in exception && typeof exception.message === 'string') {
79
+ message += ` with message '${exception.message}'`
80
+ }
81
+
82
+ return message
83
+ } else if ('message' in exception && typeof exception.message === 'string') {
84
+ return exception.message
85
+ }
86
+
87
+ const keys = extractExceptionKeysForMessage(exception)
88
+
89
+ // Some ErrorEvent instances do not have an `error` property, which is why they are not handled before
90
+ // We still want to try to get a decent message for these cases
91
+ if (isErrorEvent(exception)) {
92
+ return `Event \`ErrorEvent\` captured as exception with message \`${exception.message}\``
93
+ }
94
+
95
+ const className = getObjectClassName(exception)
96
+
97
+ return `${className && className !== 'Object' ? `'${className}'` : 'Object'} captured as exception with keys: ${keys}`
98
+ }
99
+
100
+ function getObjectClassName(obj: unknown): string | undefined | void {
101
+ try {
102
+ const prototype: unknown | null = Object.getPrototypeOf(obj)
103
+ return prototype ? prototype.constructor.name : undefined
104
+ } catch (e) {
105
+ // ignore errors here
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Given any captured exception, extract its keys and create a sorted
111
+ * and truncated list that will be used inside the event message.
112
+ * eg. `Non-error exception captured with keys: foo, bar, baz`
113
+ */
114
+ function extractExceptionKeysForMessage(exception: Record<string, unknown>, maxLength: number = 40): string {
115
+ const keys = Object.keys(convertToPlainObject(exception))
116
+ keys.sort()
117
+
118
+ const firstKey = keys[0]
119
+
120
+ if (!firstKey) {
121
+ return '[object has no keys]'
122
+ }
123
+
124
+ if (firstKey.length >= maxLength) {
125
+ return truncate(firstKey, maxLength)
126
+ }
127
+
128
+ for (let includedKeys = keys.length; includedKeys > 0; includedKeys--) {
129
+ const serialized = keys.slice(0, includedKeys).join(', ')
130
+ if (serialized.length > maxLength) {
131
+ continue
132
+ }
133
+ if (includedKeys === keys.length) {
134
+ return serialized
135
+ }
136
+ return truncate(serialized, maxLength)
137
+ }
138
+
139
+ return ''
140
+ }
141
+
142
+ function truncate(str: string, max: number = 0): string {
143
+ if (typeof str !== 'string' || max === 0) {
144
+ return str
145
+ }
146
+ return str.length <= max ? str : `${str.slice(0, max)}...`
147
+ }
148
+
149
+ /**
150
+ * Transforms any `Error` or `Event` into a plain object with all of their enumerable properties, and some of their
151
+ * non-enumerable properties attached.
152
+ *
153
+ * @param value Initial source that we have to transform in order for it to be usable by the serializer
154
+ * @returns An Event or Error turned into an object - or the value argument itself, when value is neither an Event nor
155
+ * an Error.
156
+ */
157
+ function convertToPlainObject<V>(value: V):
158
+ | {
159
+ [ownProps: string]: unknown
160
+ type: string
161
+ target: string
162
+ currentTarget: string
163
+ detail?: unknown
164
+ }
165
+ | {
166
+ [ownProps: string]: unknown
167
+ message: string
168
+ name: string
169
+ stack?: string
170
+ }
171
+ | V {
172
+ if (isError(value)) {
173
+ return {
174
+ message: value.message,
175
+ name: value.name,
176
+ stack: value.stack,
177
+ ...getOwnProperties(value),
178
+ }
179
+ } else if (isEvent(value)) {
180
+ const newObj: {
181
+ [ownProps: string]: unknown
182
+ type: string
183
+ target: string
184
+ currentTarget: string
185
+ detail?: unknown
186
+ } = {
187
+ type: value.type,
188
+ target: serializeEventTarget(value.target),
189
+ currentTarget: serializeEventTarget(value.currentTarget),
190
+ ...getOwnProperties(value),
191
+ }
192
+
193
+ // TODO: figure out why this fails typing (I think CustomEvent is only supported in Node 19 onwards)
194
+ // if (typeof CustomEvent !== 'undefined' && isInstanceOf(value, CustomEvent)) {
195
+ // newObj.detail = (value as unknown as CustomEvent).detail
196
+ // }
197
+
198
+ return newObj
199
+ } else {
200
+ return value
201
+ }
202
+ }
203
+
204
+ /** Filters out all but an object's own properties */
205
+ function getOwnProperties(obj: unknown): { [key: string]: unknown } {
206
+ if (typeof obj === 'object' && obj !== null) {
207
+ const extractedProps: { [key: string]: unknown } = {}
208
+ for (const property in obj) {
209
+ if (Object.prototype.hasOwnProperty.call(obj, property)) {
210
+ extractedProps[property] = (obj as Record<string, unknown>)[property]
211
+ }
212
+ }
213
+ return extractedProps
214
+ } else {
215
+ return {}
216
+ }
217
+ }
218
+
219
+ /** Creates a string representation of the target of an `Event` object */
220
+ function serializeEventTarget(target: unknown): string {
221
+ try {
222
+ return Object.prototype.toString.call(target)
223
+ } catch (_oO) {
224
+ return '<unknown>'
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Extracts stack frames from the error and builds an Exception
230
+ */
231
+ async function exceptionFromError(stackParser: StackParser, error: Error): Promise<Exception> {
232
+ const exception: Exception = {
233
+ type: error.name || error.constructor.name,
234
+ value: error.message,
235
+ }
236
+
237
+ const frames = await addSourceContext(parseStackFrames(stackParser, error))
238
+ if (frames.length) {
239
+ exception.stacktrace = { frames, type: 'raw' }
240
+ }
241
+
242
+ return exception
243
+ }
244
+
245
+ /**
246
+ * Extracts stack frames from the error.stack string
247
+ */
248
+ function parseStackFrames(stackParser: StackParser, error: Error): StackFrame[] {
249
+ return stackParser(error.stack || '', 1)
250
+ }
@@ -0,0 +1,36 @@
1
+ /** A simple Least Recently Used map */
2
+ export class ReduceableCache<K, V> {
3
+ private readonly _cache: Map<K, V>
4
+
5
+ public constructor(private readonly _maxSize: number) {
6
+ this._cache = new Map<K, V>()
7
+ }
8
+
9
+ /** Get an entry or undefined if it was not in the cache. Re-inserts to update the recently used order */
10
+ public get(key: K): V | undefined {
11
+ const value = this._cache.get(key)
12
+ if (value === undefined) {
13
+ return undefined
14
+ }
15
+ // Remove and re-insert to update the order
16
+ this._cache.delete(key)
17
+ this._cache.set(key, value)
18
+ return value
19
+ }
20
+
21
+ /** Insert an entry and evict an older entry if we've reached maxSize */
22
+ public set(key: K, value: V): void {
23
+ this._cache.set(key, value)
24
+ }
25
+
26
+ /** Remove an entry and return the entry if it was in the cache */
27
+ public reduce(): void {
28
+ while (this._cache.size >= this._maxSize) {
29
+ const value = this._cache.keys().next().value
30
+ if (value) {
31
+ // keys() returns an iterator in insertion order so keys().next() gives us the oldest key
32
+ this._cache.delete(value)
33
+ }
34
+ }
35
+ }
36
+ }