@flareapp/core 2.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 (51) hide show
  1. package/.oxlintrc.json +7 -0
  2. package/.release-it.json +13 -0
  3. package/CHANGELOG.md +16 -0
  4. package/README.md +22 -0
  5. package/package.json +52 -0
  6. package/src/Flare.ts +543 -0
  7. package/src/Scope.ts +96 -0
  8. package/src/api/Api.ts +35 -0
  9. package/src/api/index.ts +1 -0
  10. package/src/env/index.ts +14 -0
  11. package/src/index.ts +41 -0
  12. package/src/stacktrace/NullFileReader.ts +28 -0
  13. package/src/stacktrace/createStackTrace.ts +74 -0
  14. package/src/stacktrace/fileReader.ts +96 -0
  15. package/src/stacktrace/index.ts +4 -0
  16. package/src/types.ts +81 -0
  17. package/src/util/assert.ts +9 -0
  18. package/src/util/assertKey.ts +11 -0
  19. package/src/util/convertToError.ts +22 -0
  20. package/src/util/extractCode.ts +11 -0
  21. package/src/util/flatJsonStringify.ts +45 -0
  22. package/src/util/glowsToEvents.ts +16 -0
  23. package/src/util/index.ts +8 -0
  24. package/src/util/now.ts +3 -0
  25. package/src/util/redactUrl.ts +83 -0
  26. package/tests/api.test.ts +95 -0
  27. package/tests/configure.test.ts +16 -0
  28. package/tests/contextCollector.test.ts +37 -0
  29. package/tests/convertToError.test.ts +95 -0
  30. package/tests/createStackTrace.test.ts +54 -0
  31. package/tests/extractCode.test.ts +30 -0
  32. package/tests/fileReader.test.ts +51 -0
  33. package/tests/flatJsonStringify.test.ts +31 -0
  34. package/tests/flush.test.ts +47 -0
  35. package/tests/glows.test.ts +47 -0
  36. package/tests/glowsToEvents.test.ts +41 -0
  37. package/tests/helpers/FakeApi.ts +20 -0
  38. package/tests/helpers/index.ts +1 -0
  39. package/tests/hooks.test.ts +123 -0
  40. package/tests/light.test.ts +25 -0
  41. package/tests/nullFileReader.test.ts +11 -0
  42. package/tests/publicExports.test.ts +17 -0
  43. package/tests/redactUrl.test.ts +151 -0
  44. package/tests/report.test.ts +146 -0
  45. package/tests/sampleRate.test.ts +88 -0
  46. package/tests/scope.test.ts +64 -0
  47. package/tests/setEntryPoint.test.ts +79 -0
  48. package/tests/setFramework.test.ts +48 -0
  49. package/tests/setSdkInfo.test.ts +62 -0
  50. package/tsconfig.json +4 -0
  51. package/vitest.config.ts +17 -0
package/src/Scope.ts ADDED
@@ -0,0 +1,96 @@
1
+ import type { Attributes, AttributeValue, EntryPointHandler, Glow } from './types';
2
+
3
+ /**
4
+ * Holds the per-call mutable state that used to live on the `Flare` instance:
5
+ * breadcrumbs (`glows`), custom attributes (`pendingAttributes`), and the
6
+ * current entry-point handler.
7
+ *
8
+ * Why this exists as its own class: in the browser there is one `Flare` per
9
+ * page and one user at a time, so a single shared bag of state is fine. In
10
+ * Node, a single `Flare` instance serves many concurrent requests, and each
11
+ * request wants its own breadcrumbs and its own custom context that do NOT
12
+ * leak into other requests. Splitting this state out of `Flare` lets the
13
+ * consumer choose: one global `Scope` (browser) or one `Scope` per request
14
+ * via AsyncLocalStorage (Node).
15
+ *
16
+ * `Flare` reads and writes this through `scopeProvider.active()` instead of
17
+ * holding the state directly, so the per-request behavior comes from the
18
+ * provider, not from the class itself.
19
+ *
20
+ * `NodeScope` (in `@flareapp/node`) extends this with two more buckets:
21
+ * `request` (HTTP method, path, headers) and `user` (id, email, ...). Browser
22
+ * does not need those.
23
+ */
24
+ export class Scope {
25
+ glows: Glow[] = [];
26
+ pendingAttributes: Attributes = {};
27
+ entryPoint: EntryPointHandler | null = null;
28
+
29
+ /**
30
+ * Append a breadcrumb. Caps the list at `maxGlowsPerReport` by dropping the
31
+ * OLDEST entries when the limit is exceeded; this keeps reports below a
32
+ * payload-size threshold while preserving the most recent events leading
33
+ * up to an error.
34
+ *
35
+ * `slice(length - max)` returns the trailing `max` items, which is the
36
+ * shortest way to drop from the front and keep insertion order.
37
+ */
38
+ addGlow(glow: Glow, maxGlowsPerReport: number): void {
39
+ this.glows.push(glow);
40
+ if (this.glows.length > maxGlowsPerReport) {
41
+ this.glows = this.glows.slice(this.glows.length - maxGlowsPerReport);
42
+ }
43
+ }
44
+
45
+ clearGlows(): void {
46
+ this.glows = [];
47
+ }
48
+
49
+ /**
50
+ * Set a single attribute on this scope. Called from `Flare.addContext` and
51
+ * `Flare.addContextGroup`. Last write wins.
52
+ */
53
+ setAttribute(key: string, value: AttributeValue): void {
54
+ this.pendingAttributes[key] = value;
55
+ }
56
+
57
+ /**
58
+ * Shallow-merge a bag of attributes into this scope. Used by Node's
59
+ * AsyncLocalStorage provider when patching the live request context via
60
+ * `flare.mergeContext({ ... })`. Last write wins per key; nested objects
61
+ * are NOT deep-merged.
62
+ */
63
+ mergeAttributes(partial: Attributes): void {
64
+ Object.assign(this.pendingAttributes, partial);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * The seam through which `Flare` reaches its current `Scope`. Implementations
70
+ * decide what "current" means.
71
+ *
72
+ * - `GlobalScopeProvider` always returns the same `Scope` instance (browser).
73
+ * - `AsyncLocalStorageScopeProvider` in `@flareapp/node` returns the per-request
74
+ * `NodeScope` stored in `node:async_hooks` for the in-flight async chain,
75
+ * falling back to a single shared scope when called outside any
76
+ * `runWithContext(...)` callback.
77
+ *
78
+ * Any consumer of `@flareapp/core` can supply its own provider to plug in
79
+ * different "current scope" semantics.
80
+ */
81
+ export interface ScopeProvider {
82
+ active(): Scope;
83
+ }
84
+
85
+ /**
86
+ * The simplest provider: one `Scope` for the lifetime of the provider, shared
87
+ * by every caller. This is the right default for environments with a single
88
+ * logical context (browser tab, CLI script, etc.) and is the default that
89
+ * `Flare`'s constructor falls back to when no provider is supplied.
90
+ */
91
+ export class GlobalScopeProvider implements ScopeProvider {
92
+ private scope = new Scope();
93
+ active(): Scope {
94
+ return this.scope;
95
+ }
96
+ }
package/src/api/Api.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { Report } from '../types';
2
+ import { flatJsonStringify } from '../util';
3
+
4
+ export class Api {
5
+ report(
6
+ report: Report,
7
+ url: string,
8
+ key: string | null,
9
+ reportBrowserExtensionErrors: boolean,
10
+ debug: boolean = false,
11
+ ): Promise<void> {
12
+ return fetch(url, {
13
+ method: 'POST',
14
+ headers: {
15
+ 'Accept': 'application/json',
16
+ 'Content-Type': 'application/json',
17
+ 'X-Api-Token': key ?? '',
18
+ 'X-Report-Browser-Extension-Errors': JSON.stringify(reportBrowserExtensionErrors),
19
+ 'X-Flare-Client-Version': '2',
20
+ },
21
+ body: flatJsonStringify(report),
22
+ }).then(
23
+ (response) => {
24
+ if (debug && response.status !== 201) {
25
+ console.error(`Received response with status ${response.status} from Flare`);
26
+ }
27
+ },
28
+ (error) => {
29
+ if (debug) {
30
+ console.error(error);
31
+ }
32
+ },
33
+ );
34
+ }
35
+ }
@@ -0,0 +1 @@
1
+ export { Api } from './Api';
@@ -0,0 +1,14 @@
1
+ declare const FLARE_JS_KEY: string | undefined;
2
+ declare const FLARE_SOURCEMAP_VERSION: string | undefined;
3
+
4
+ // Injected during build
5
+ export const CLIENT_VERSION =
6
+ typeof process !== 'undefined' && typeof process.env?.FLARE_JS_CLIENT_VERSION !== 'undefined'
7
+ ? process.env.FLARE_JS_CLIENT_VERSION
8
+ : '?';
9
+
10
+ // Injected by flare-vite-plugin-sourcemap-uploader (optional)
11
+ export const KEY = typeof FLARE_JS_KEY === 'undefined' ? '' : FLARE_JS_KEY;
12
+
13
+ // Injected by flare-vite-plugin-sourcemap-uploader (optional)
14
+ export const SOURCEMAP_VERSION = typeof FLARE_SOURCEMAP_VERSION === 'undefined' ? '' : FLARE_SOURCEMAP_VERSION;
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ export type {
2
+ AttributeValue,
3
+ Attributes,
4
+ Config,
5
+ EntryPointHandler,
6
+ Framework,
7
+ Glow,
8
+ MessageLevel,
9
+ OverriddenGrouping,
10
+ Report,
11
+ SdkInfo,
12
+ SpanEvent,
13
+ StackFrame,
14
+ } from './types';
15
+
16
+ export {
17
+ assert,
18
+ assertKey,
19
+ convertToError,
20
+ DEFAULT_URL_DENYLIST,
21
+ extractCode,
22
+ flatJsonStringify,
23
+ glowsToEvents,
24
+ now,
25
+ redactUrlQuery,
26
+ resolveDenylist,
27
+ } from './util';
28
+
29
+ export { Api } from './api';
30
+
31
+ export { Flare } from './Flare';
32
+ export type { ContextCollector } from './Flare';
33
+
34
+ export { Scope, GlobalScopeProvider } from './Scope';
35
+ export type { ScopeProvider } from './Scope';
36
+
37
+ export { NullFileReader } from './stacktrace/NullFileReader';
38
+ export type { FileReader } from './stacktrace/fileReader';
39
+
40
+ export { createStackTrace } from './stacktrace/createStackTrace';
41
+ export { getCodeSnippet, readLinesFromFile } from './stacktrace/fileReader';
@@ -0,0 +1,28 @@
1
+ import type { FileReader } from './fileReader';
2
+
3
+ /**
4
+ * No-op `FileReader` that returns `null` for every URL it is asked to read.
5
+ *
6
+ * Used as the default for `Flare`'s `fileReader` constructor parameter so the
7
+ * class is usable without picking a side: instantiated bare (`new Flare()`),
8
+ * reports still build, but stack frames omit source-code snippets — which is
9
+ * the correct, safe behavior in an environment we know nothing about.
10
+ *
11
+ * The two real implementations live in the consumer packages and take their
12
+ * place once the right environment is established:
13
+ *
14
+ * - `@flareapp/js` injects `FetchFileReader`, which `fetch()`s source maps
15
+ * and original files over HTTP for browser stack frames.
16
+ * - `@flareapp/node` injects `DiskFileReader`, which reads files from disk
17
+ * via `node:fs/promises` for server stack frames.
18
+ *
19
+ * The interface (`read(url) -> Promise<string | null>`) lets the stack-trace
20
+ * builder treat all three the same way: ask for a URL, render the snippet
21
+ * when text comes back, gracefully skip it when `null` does. No environment
22
+ * checks anywhere in core.
23
+ */
24
+ export class NullFileReader implements FileReader {
25
+ read(_url: string): Promise<string | null> {
26
+ return Promise.resolve(null);
27
+ }
28
+ }
@@ -0,0 +1,74 @@
1
+ import ErrorStackParser from 'error-stack-parser';
2
+
3
+ import { StackFrame } from '../types';
4
+ import { assert } from '../util';
5
+ import type { FileReader } from './fileReader';
6
+ import { getCodeSnippet } from './fileReader';
7
+
8
+ export function createStackTrace(error: Error, debug: boolean, fileReader: FileReader): Promise<Array<StackFrame>> {
9
+ return new Promise((resolve) => {
10
+ if (!hasStack(error)) {
11
+ return resolve([fallbackFrame('stacktrace missing')]);
12
+ }
13
+
14
+ let parsedFrames;
15
+ try {
16
+ parsedFrames = ErrorStackParser.parse(error);
17
+ } catch (parseError) {
18
+ assert(false, "Couldn't parse stacktrace of below error:", debug);
19
+ if (debug) {
20
+ console.error(parseError);
21
+ console.error(error);
22
+ }
23
+ return resolve([fallbackFrame('stacktrace could not be parsed')]);
24
+ }
25
+
26
+ Promise.all(
27
+ parsedFrames.map((frame) => {
28
+ return getCodeSnippet(fileReader, frame.fileName, frame.lineNumber, frame.columnNumber).then(
29
+ (snippet) => ({
30
+ lineNumber: frame.lineNumber || 1,
31
+ columnNumber: frame.columnNumber || 1,
32
+ method: frame.functionName || 'Anonymous or unknown function',
33
+ file: frame.fileName || 'Unknown file',
34
+ codeSnippet: snippet.codeSnippet,
35
+ class: '',
36
+ isApplicationFrame: isApplicationFrame(frame.fileName),
37
+ }),
38
+ );
39
+ }),
40
+ ).then(resolve);
41
+ });
42
+ }
43
+
44
+ function fallbackFrame(reason: string): StackFrame {
45
+ return {
46
+ lineNumber: 0,
47
+ columnNumber: 0,
48
+ method: 'unknown',
49
+ file: 'unknown',
50
+ codeSnippet: { 0: `Could not read from file: ${reason}` },
51
+ class: 'unknown',
52
+ };
53
+ }
54
+
55
+ // Some engines populate `err.stack` with just `"<Name>: <message>"` (no frames) when an Error is
56
+ // constructed but never thrown. Treat that as "no stack" so we fall back instead of parsing garbage.
57
+ // Also accepts the legacy `stacktrace` and Opera `opera#sourceloc` properties.
58
+ function hasStack(err: unknown): boolean {
59
+ if (!err || typeof err !== 'object') return false;
60
+ const e = err as Record<string, unknown>;
61
+ const stack = e.stack ?? e.stacktrace ?? e['opera#sourceloc'];
62
+ return (
63
+ typeof stack === 'string' &&
64
+ stack !== `${(e as { name?: string }).name}: ${(e as { message?: string }).message}`
65
+ );
66
+ }
67
+
68
+ function isApplicationFrame(fileName: string | undefined): boolean {
69
+ if (!fileName) return true;
70
+ // node_modules and webpack-style vendor chunks should not count as application code
71
+ if (/[/\\]node_modules[/\\]/.test(fileName)) return false;
72
+ if (/(^|[/\\])(vendor|vendors)[.~-][^/\\]*\.js/i.test(fileName)) return false;
73
+ return true;
74
+ }
@@ -0,0 +1,96 @@
1
+ export interface FileReader {
2
+ read(url: string): Promise<string | null>;
3
+ }
4
+
5
+ const cachedFiles: { [key: string]: string } = {};
6
+
7
+ type CodeSnippet = { [key: number]: string };
8
+
9
+ type ReaderResponse = {
10
+ codeSnippet: CodeSnippet;
11
+ trimmedColumnNumber: number | null;
12
+ };
13
+
14
+ export function getCodeSnippet(
15
+ fileReader: FileReader,
16
+ url?: string,
17
+ lineNumber?: number,
18
+ columnNumber?: number,
19
+ ): Promise<ReaderResponse> {
20
+ return new Promise((resolve) => {
21
+ if (!url || !lineNumber) {
22
+ return resolve({
23
+ codeSnippet: {
24
+ 0: `Could not read from file: missing file URL or line number. URL: ${url} lineNumber: ${lineNumber}`,
25
+ },
26
+ trimmedColumnNumber: null,
27
+ });
28
+ }
29
+
30
+ readFile(fileReader, url).then((fileText) => {
31
+ if (!fileText) {
32
+ return resolve({
33
+ codeSnippet: {
34
+ 0: `Could not read from file: Error while opening file at URL ${url}`,
35
+ },
36
+ trimmedColumnNumber: null,
37
+ });
38
+ }
39
+ return resolve(readLinesFromFile(fileText, lineNumber, columnNumber));
40
+ });
41
+ });
42
+ }
43
+
44
+ function readFile(fileReader: FileReader, url: string): Promise<string | null> {
45
+ if (cachedFiles[url] !== undefined) {
46
+ return Promise.resolve(cachedFiles[url]);
47
+ }
48
+ return fileReader.read(url).then((text) => {
49
+ if (text !== null) {
50
+ cachedFiles[url] = text;
51
+ }
52
+ return text;
53
+ });
54
+ }
55
+
56
+ export function readLinesFromFile(
57
+ fileText: string,
58
+ lineNumber: number,
59
+ columnNumber?: number,
60
+ maxSnippetLineLength = 1000,
61
+ maxSnippetLines = 40,
62
+ ): ReaderResponse {
63
+ const codeSnippet: CodeSnippet = {};
64
+ let trimmedColumnNumber = null;
65
+
66
+ const lines = fileText.split('\n');
67
+ const errorLineIndex = lineNumber - 1;
68
+ const half = Math.floor(maxSnippetLines / 2);
69
+
70
+ for (let i = -half; i <= half; i++) {
71
+ const currentLineIndex = errorLineIndex + i;
72
+ if (currentLineIndex < 0 || !lines[currentLineIndex]) continue;
73
+ const displayLine = currentLineIndex + 1;
74
+ const line = lines[currentLineIndex];
75
+ if (line.length > maxSnippetLineLength) {
76
+ if (columnNumber && columnNumber > maxSnippetLineLength / 2) {
77
+ const start = columnNumber - Math.round(maxSnippetLineLength / 2);
78
+ codeSnippet[displayLine] = line.slice(start, start + maxSnippetLineLength);
79
+ if (displayLine === lineNumber) {
80
+ trimmedColumnNumber = Math.round(maxSnippetLineLength / 2);
81
+ }
82
+ continue;
83
+ }
84
+ codeSnippet[displayLine] = line.slice(0, maxSnippetLineLength) + '…';
85
+ continue;
86
+ }
87
+ codeSnippet[displayLine] = line;
88
+ }
89
+
90
+ return { codeSnippet, trimmedColumnNumber };
91
+ }
92
+
93
+ // Clearing the cache is useful in tests.
94
+ export function __clearFileReaderCacheForTests(): void {
95
+ for (const key of Object.keys(cachedFiles)) delete cachedFiles[key];
96
+ }
@@ -0,0 +1,4 @@
1
+ export { createStackTrace } from './createStackTrace';
2
+ export { getCodeSnippet, readLinesFromFile, __clearFileReaderCacheForTests } from './fileReader';
3
+ export type { FileReader } from './fileReader';
4
+ export { NullFileReader } from './NullFileReader';
package/src/types.ts ADDED
@@ -0,0 +1,81 @@
1
+ export type MessageLevel = 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency';
2
+
3
+ export type AttributeValue = string | number | boolean | null | AttributeValue[] | { [key: string]: AttributeValue };
4
+
5
+ export type Attributes = Record<string, AttributeValue>;
6
+
7
+ export type Config = {
8
+ key: string | null;
9
+ version: string;
10
+ sourcemapVersionId: string;
11
+ stage: string;
12
+ maxGlowsPerReport: number;
13
+ reportBrowserExtensionErrors: boolean;
14
+ ingestUrl: string;
15
+ debug: boolean;
16
+ urlDenylist: RegExp;
17
+ replaceDefaultUrlDenylist: boolean;
18
+ sampleRate: number;
19
+ beforeEvaluate: (error: Error) => Error | false | null | Promise<Error | false | null>;
20
+ beforeSubmit: (report: Report) => Report | false | null | Promise<Report | false | null>;
21
+ };
22
+
23
+ export type StackFrame = {
24
+ file: string;
25
+ lineNumber: number;
26
+ columnNumber?: number;
27
+ method?: string;
28
+ class?: string;
29
+ codeSnippet?: { [line: number]: string };
30
+ isApplicationFrame?: boolean;
31
+ arguments?: unknown[];
32
+ };
33
+
34
+ export type SpanEvent = {
35
+ type: string;
36
+ startTimeUnixNano: number;
37
+ endTimeUnixNano: number | null;
38
+ attributes: Attributes;
39
+ };
40
+
41
+ export type OverriddenGrouping =
42
+ | 'exception_class'
43
+ | 'exception_message'
44
+ | 'exception_message_and_class'
45
+ | 'full_stacktrace_and_exception_class_and_code';
46
+
47
+ export type Report = {
48
+ exceptionClass?: string | null;
49
+ message?: string | null;
50
+ code?: string;
51
+ seenAtUnixNano: number;
52
+ isLog?: boolean;
53
+ level?: MessageLevel;
54
+ sourcemapVersionId?: string;
55
+ trackingUuid?: string;
56
+ handled?: boolean;
57
+ openFrameIndex?: number;
58
+ applicationPath?: string;
59
+ overriddenGrouping?: OverriddenGrouping | null;
60
+ stacktrace: StackFrame[];
61
+ events: SpanEvent[];
62
+ attributes: Attributes;
63
+ };
64
+
65
+ export type Glow = {
66
+ time: number;
67
+ microtime: number;
68
+ name: string;
69
+ messageLevel: MessageLevel;
70
+ metaData: Record<string, unknown> | Record<string, unknown>[];
71
+ };
72
+
73
+ export type EntryPointHandler = {
74
+ identifier?: string;
75
+ name?: string;
76
+ type?: string;
77
+ };
78
+
79
+ export type SdkInfo = { name: string; version: string };
80
+
81
+ export type Framework = { name: string; version?: string };
@@ -0,0 +1,9 @@
1
+ import { CLIENT_VERSION } from '../env';
2
+
3
+ export function assert(value: unknown, message: string, debug: boolean) {
4
+ if (debug && !value) {
5
+ console.error(`Flare JavaScript client v${CLIENT_VERSION}: ${message}`);
6
+ }
7
+
8
+ return !!value;
9
+ }
@@ -0,0 +1,11 @@
1
+ import { assert } from './assert';
2
+
3
+ export function assertKey(key: unknown, debug: boolean): boolean {
4
+ return assert(
5
+ key,
6
+ 'The client was not yet initialised with an API key. ' +
7
+ "Run client.light('<flare-project-key>') when you initialise your app. " +
8
+ "If you are running in dev mode and didn't run the light command on purpose, you can ignore this error.",
9
+ debug,
10
+ );
11
+ }
@@ -0,0 +1,22 @@
1
+ export function convertToError(error: unknown): Error {
2
+ if (error instanceof Error) {
3
+ return error;
4
+ }
5
+ if (typeof error === 'string') {
6
+ return new Error(error);
7
+ }
8
+ if (typeof error === 'object' && error !== null) {
9
+ const obj = error as Record<string, unknown>;
10
+ const message = typeof obj.message === 'string' ? obj.message : String(error);
11
+ const converted = new Error(message);
12
+ if (typeof obj.stack === 'string') {
13
+ converted.stack = obj.stack;
14
+ }
15
+ if (typeof obj.name === 'string') {
16
+ converted.name = obj.name;
17
+ }
18
+ return converted;
19
+ }
20
+
21
+ return new Error(String(error));
22
+ }
@@ -0,0 +1,11 @@
1
+ const MAX_CODE_LENGTH = 64;
2
+
3
+ export function extractCode(error: Error): string | undefined {
4
+ const code = (error as { code?: unknown }).code;
5
+
6
+ if (typeof code !== 'string' || code.length === 0) {
7
+ return undefined;
8
+ }
9
+
10
+ return code.slice(0, MAX_CODE_LENGTH);
11
+ }
@@ -0,0 +1,45 @@
1
+ // JSON.stringify throws on circular references. User-supplied glow data and addContext values can
2
+ // realistically contain cycles (e.g. Vue/React component instances), so we walk the tree first and
3
+ // replace any back-edges with the sentinel "[Circular]" before serialising.
4
+ export function flatJsonStringify(json: object): string {
5
+ return JSON.stringify(decycle(json));
6
+ }
7
+
8
+ // Restricted to literal object/null prototypes on purpose: class instances may have getters with
9
+ // side effects or non-enumerable internals that we shouldn't traverse. They're left to JSON.stringify
10
+ // to handle (typically by calling toJSON or returning {}).
11
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
12
+ if (typeof value !== 'object' || value === null) return false;
13
+ const proto = Object.getPrototypeOf(value);
14
+ return proto === Object.prototype || proto === null;
15
+ }
16
+
17
+ function decycle(root: unknown): unknown {
18
+ // `inPath` tracks ancestors on the current branch only (added on enter, removed on exit). Using a
19
+ // global "seen" set would mis-flag the same object referenced twice in different branches as
20
+ // circular, even though no cycle exists.
21
+ const inPath = new WeakSet<object>();
22
+
23
+ function clone(node: unknown): unknown {
24
+ if (Array.isArray(node)) {
25
+ if (inPath.has(node)) return '[Circular]';
26
+ inPath.add(node);
27
+ const result = node.map(clone);
28
+ inPath.delete(node);
29
+ return result;
30
+ }
31
+ if (isPlainObject(node)) {
32
+ if (inPath.has(node)) return '[Circular]';
33
+ inPath.add(node);
34
+ const result: Record<string, unknown> = {};
35
+ for (const [k, v] of Object.entries(node)) {
36
+ result[k] = clone(v);
37
+ }
38
+ inPath.delete(node);
39
+ return result;
40
+ }
41
+ return node;
42
+ }
43
+
44
+ return clone(root);
45
+ }
@@ -0,0 +1,16 @@
1
+ import { AttributeValue, Glow, SpanEvent } from '../types';
2
+
3
+ // glow.microtime is seconds since epoch (see util/now.ts).
4
+ // SpanEvent.startTimeUnixNano is unix nanoseconds.
5
+ export function glowsToEvents(glows: Glow[]): SpanEvent[] {
6
+ return glows.map((glow) => ({
7
+ type: 'php_glow',
8
+ startTimeUnixNano: Math.round(glow.microtime * 1_000_000_000),
9
+ endTimeUnixNano: null,
10
+ attributes: {
11
+ 'glow.name': String(glow.name),
12
+ 'glow.level': glow.messageLevel,
13
+ 'glow.context': (glow.metaData ?? {}) as AttributeValue,
14
+ },
15
+ }));
16
+ }
@@ -0,0 +1,8 @@
1
+ export * from './assert';
2
+ export * from './assertKey';
3
+ export * from './convertToError';
4
+ export * from './extractCode';
5
+ export * from './flatJsonStringify';
6
+ export * from './glowsToEvents';
7
+ export * from './now';
8
+ export * from './redactUrl';
@@ -0,0 +1,3 @@
1
+ export function now(): number {
2
+ return Math.round(Date.now() / 1000);
3
+ }