@flareapp/core 2.2.1 → 2.3.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.
package/README.md CHANGED
@@ -12,9 +12,12 @@ the official SDKs use.
12
12
 
13
13
  - `Flare` — the core class. Takes three optional injection points: `ScopeProvider`,
14
14
  `ContextCollector`, `FileReader`.
15
+ - `Logger` — structured logging (`flare.logger`). Eight syslog levels, opt-in via
16
+ `enableLogs`; owns the log buffer, batching policy, and OTel envelope. A
17
+ `FlushScheduler` seam is injected per platform.
15
18
  - `Scope`, `GlobalScopeProvider`, `ScopeProvider` — per-call mutable state.
16
19
  - `FileReader`, `NullFileReader` — source-snippet reading abstraction.
17
- - `Api` — the HTTP client used to send reports.
20
+ - `Api` — the HTTP client used to send reports and logs.
18
21
  - Types: `Config`, `Report`, `Attributes`, `Glow`, `StackFrame`, etc.
19
22
  - Util: `redactUrlQuery`, `resolveDenylist`, `convertToError`, `DEFAULT_URL_DENYLIST`.
20
23
 
package/dist/index.cjs CHANGED
@@ -30,7 +30,7 @@ let error_stack_parser = require("error-stack-parser");
30
30
  error_stack_parser = __toESM(error_stack_parser);
31
31
 
32
32
  //#region src/env/index.ts
33
- const CLIENT_VERSION = typeof process !== "undefined" && true ? "2.2.1" : "?";
33
+ const CLIENT_VERSION = typeof process !== "undefined" && true ? "2.3.0" : "?";
34
34
  const KEY = typeof FLARE_JS_KEY === "undefined" ? "" : FLARE_JS_KEY;
35
35
  const SOURCEMAP_VERSION = typeof FLARE_SOURCEMAP_VERSION === "undefined" ? "" : FLARE_SOURCEMAP_VERSION;
36
36
 
@@ -191,7 +191,311 @@ var Api = class {
191
191
  if (debug) console.error(error);
192
192
  });
193
193
  }
194
+ logs(envelope, url, key, debug = false, keepalive = false) {
195
+ return fetch(url, {
196
+ method: "POST",
197
+ keepalive,
198
+ headers: {
199
+ "Accept": "application/json",
200
+ "Content-Type": "application/json",
201
+ "x-api-token": key ?? ""
202
+ },
203
+ body: flatJsonStringify(envelope)
204
+ }).then((response) => {
205
+ if (debug && response.status !== 201) console.error(`Received response with status ${response.status} from Flare logs`);
206
+ }, (error) => {
207
+ if (debug) console.error(error);
208
+ });
209
+ }
210
+ };
211
+
212
+ //#endregion
213
+ //#region src/logging/otel.ts
214
+ function valueToOpenTelemetry(value, inPath = /* @__PURE__ */ new WeakSet()) {
215
+ if (typeof value === "string") return { stringValue: value };
216
+ if (typeof value === "boolean") return { boolValue: value };
217
+ if (typeof value === "number") {
218
+ if (!Number.isFinite(value)) return null;
219
+ return Number.isInteger(value) ? { intValue: value } : { doubleValue: value };
220
+ }
221
+ if (value === null || value === void 0) return null;
222
+ if (Array.isArray(value)) {
223
+ if (inPath.has(value)) return { stringValue: "[Circular]" };
224
+ inPath.add(value);
225
+ const values = [];
226
+ for (const item of value) {
227
+ const mapped = valueToOpenTelemetry(item, inPath);
228
+ if (mapped !== null) values.push(mapped);
229
+ }
230
+ inPath.delete(value);
231
+ return { arrayValue: { values } };
232
+ }
233
+ if (typeof value === "object") {
234
+ if (inPath.has(value)) return { stringValue: "[Circular]" };
235
+ inPath.add(value);
236
+ const values = [];
237
+ for (const [key, item] of Object.entries(value)) {
238
+ const mapped = valueToOpenTelemetry(item, inPath);
239
+ if (mapped !== null) values.push({
240
+ key,
241
+ value: mapped
242
+ });
243
+ }
244
+ inPath.delete(value);
245
+ return { kvlistValue: { values } };
246
+ }
247
+ return null;
248
+ }
249
+ function attributesToOpenTelemetry(attributes) {
250
+ const out = [];
251
+ for (const [key, value] of Object.entries(attributes)) {
252
+ const mapped = valueToOpenTelemetry(value);
253
+ if (mapped !== null) out.push({
254
+ key,
255
+ value: mapped
256
+ });
257
+ }
258
+ return out;
259
+ }
260
+
261
+ //#endregion
262
+ //#region src/logging/envelope.ts
263
+ function buildLogsEnvelope(records, resourceAttributes, scopeName, scopeVersion) {
264
+ return { resourceLogs: [{
265
+ resource: {
266
+ attributes: attributesToOpenTelemetry(resourceAttributes),
267
+ droppedAttributesCount: 0
268
+ },
269
+ scopeLogs: [{
270
+ scope: {
271
+ name: scopeName,
272
+ version: scopeVersion,
273
+ attributes: [],
274
+ droppedAttributesCount: 0
275
+ },
276
+ logRecords: records.map((record) => ({
277
+ timeUnixNano: record.timeUnixNano,
278
+ observedTimeUnixNano: record.timeUnixNano,
279
+ severityNumber: record.severityNumber,
280
+ severityText: record.severityText,
281
+ body: { stringValue: record.message },
282
+ attributes: record.recordAttributes,
283
+ flags: 0,
284
+ droppedAttributesCount: 0
285
+ }))
286
+ }]
287
+ }] };
288
+ }
289
+
290
+ //#endregion
291
+ //#region src/logging/severity.ts
292
+ const SEVERITY_NUMBERS = {
293
+ debug: 5,
294
+ info: 9,
295
+ notice: 10,
296
+ warning: 13,
297
+ error: 17,
298
+ critical: 18,
299
+ alert: 19,
300
+ emergency: 21
194
301
  };
302
+ function severityNumber(level) {
303
+ return SEVERITY_NUMBERS[level];
304
+ }
305
+ function severityText(level) {
306
+ return level.toUpperCase();
307
+ }
308
+ function isAtOrAboveMinimum(level, minimum) {
309
+ return severityNumber(level) >= severityNumber(minimum);
310
+ }
311
+
312
+ //#endregion
313
+ //#region src/logging/Logger.ts
314
+ var Logger = class {
315
+ buffer = [];
316
+ resourceAttributes = {};
317
+ timer;
318
+ timerActive = false;
319
+ constructor(deps) {
320
+ this.deps = deps;
321
+ const flush = (opts) => this.flush(opts);
322
+ this.deps.scheduler.register(flush);
323
+ }
324
+ debug(message, context = {}, attributes = {}) {
325
+ this.record("debug", message, context, attributes);
326
+ }
327
+ info(message, context = {}, attributes = {}) {
328
+ this.record("info", message, context, attributes);
329
+ }
330
+ notice(message, context = {}, attributes = {}) {
331
+ this.record("notice", message, context, attributes);
332
+ }
333
+ warning(message, context = {}, attributes = {}) {
334
+ this.record("warning", message, context, attributes);
335
+ }
336
+ error(message, context = {}, attributes = {}) {
337
+ this.record("error", message, context, attributes);
338
+ }
339
+ critical(message, context = {}, attributes = {}) {
340
+ this.record("critical", message, context, attributes);
341
+ }
342
+ alert(message, context = {}, attributes = {}) {
343
+ this.record("alert", message, context, attributes);
344
+ }
345
+ emergency(message, context = {}, attributes = {}) {
346
+ this.record("emergency", message, context, attributes);
347
+ }
348
+ bufferLength() {
349
+ return this.buffer.length;
350
+ }
351
+ record(level, message, context, attributes) {
352
+ const config = this.deps.getConfig();
353
+ if (!config.enableLogs) return;
354
+ if (config.minimumLogLevel && !isAtOrAboveMinimum(level, config.minimumLogLevel)) return;
355
+ const userAttributes = {
356
+ "log.context": context,
357
+ ...attributes
358
+ };
359
+ const { record, resource } = this.deps.buildLogAttributes(userAttributes);
360
+ const buffered = {
361
+ timeUnixNano: String(Date.now()) + "000000",
362
+ severityNumber: severityNumber(level),
363
+ severityText: severityText(level),
364
+ message,
365
+ recordAttributes: attributesToOpenTelemetry(record),
366
+ resourceAttributes: resource
367
+ };
368
+ if (this.estimateBytes(buffered) > config.logFlushMaxBytes) {
369
+ if (config.debug) console.error("Flare: dropping oversized log record");
370
+ return;
371
+ }
372
+ this.buffer.push(buffered);
373
+ this.resourceAttributes = resource;
374
+ this.evaluateTriggers(config);
375
+ this.trim(config);
376
+ }
377
+ evaluateTriggers(config) {
378
+ if (this.buffer.length >= config.maxLogBufferSize) {
379
+ this.flush();
380
+ return;
381
+ }
382
+ if (this.bufferBytes() >= config.logFlushMaxBytes) {
383
+ this.flush();
384
+ return;
385
+ }
386
+ this.armTimer(config);
387
+ }
388
+ armTimer(config) {
389
+ if (this.timerActive) return;
390
+ this.timerActive = true;
391
+ this.timer = setTimeout(() => this.flush(), config.logFlushIntervalMs);
392
+ this.timer.unref?.();
393
+ }
394
+ trim(config) {
395
+ if (this.buffer.length > config.maxLogBufferSize) this.buffer = this.buffer.slice(this.buffer.length - config.maxLogBufferSize);
396
+ while (this.buffer.length > 1 && this.bufferBytes() > config.logFlushMaxBytes) this.buffer.shift();
397
+ }
398
+ flush(opts) {
399
+ const config = this.deps.getConfig();
400
+ if (!config.enableLogs) return;
401
+ if (this.buffer.length === 0) return;
402
+ if (!assertKey(config.key, config.debug)) {
403
+ this.clearTimer();
404
+ return;
405
+ }
406
+ this.clearTimer();
407
+ let records;
408
+ if (opts?.keepalive) {
409
+ records = this.packForKeepalive(config);
410
+ this.buffer = this.buffer.filter((log) => !records.includes(log));
411
+ if (this.buffer.length > 0) this.armTimer(config);
412
+ } else {
413
+ records = this.buffer;
414
+ this.buffer = [];
415
+ }
416
+ if (records.length === 0) return;
417
+ this.deps.track(this.deps.api.logs(this.buildEnvelope(records), config.logsIngestUrl, config.key, config.debug, !!opts?.keepalive));
418
+ }
419
+ clear() {
420
+ this.buffer = [];
421
+ this.clearTimer();
422
+ }
423
+ packForKeepalive(config) {
424
+ let selected = [];
425
+ for (let i = this.buffer.length - 1; i >= 0; i--) {
426
+ const trial = [this.buffer[i], ...selected];
427
+ if (new TextEncoder().encode(flatJsonStringify(this.buildEnvelope(trial))).length <= config.keepaliveMaxBytes) selected = trial;
428
+ else if (config.debug) console.error("Flare: dropping log record from keepalive envelope (over budget)");
429
+ }
430
+ return selected;
431
+ }
432
+ buildEnvelope(records) {
433
+ const sdk = this.deps.getSdkInfo();
434
+ return buildLogsEnvelope(records, this.resourceForFlush(), sdk.name, sdk.version);
435
+ }
436
+ resourceForFlush() {
437
+ const config = this.deps.getConfig();
438
+ const sdk = this.deps.getSdkInfo();
439
+ const framework = this.deps.getFramework();
440
+ const identity = {
441
+ "telemetry.sdk.language": "javascript",
442
+ "telemetry.sdk.name": sdk.name,
443
+ "telemetry.sdk.version": sdk.version,
444
+ "flare.language.name": "javascript"
445
+ };
446
+ if (config.serviceName) identity["service.name"] = config.serviceName;
447
+ if (config.version) identity["service.version"] = config.version;
448
+ if (config.stage) identity["service.stage"] = config.stage;
449
+ if (framework?.name) identity["flare.framework.name"] = framework.name;
450
+ if (framework?.version) identity["flare.framework.version"] = framework.version;
451
+ return {
452
+ ...this.resourceAttributes,
453
+ ...identity
454
+ };
455
+ }
456
+ clearTimer() {
457
+ if (this.timer) {
458
+ clearTimeout(this.timer);
459
+ this.timer = void 0;
460
+ }
461
+ this.timerActive = false;
462
+ }
463
+ estimateBytes(log) {
464
+ return flatJsonStringify(log).length;
465
+ }
466
+ bufferBytes() {
467
+ return this.buffer.reduce((sum, log) => sum + this.estimateBytes(log), 0);
468
+ }
469
+ };
470
+
471
+ //#endregion
472
+ //#region src/logging/FlushScheduler.ts
473
+ var NoopFlushScheduler = class {
474
+ register() {}
475
+ };
476
+
477
+ //#endregion
478
+ //#region src/logging/partition.ts
479
+ const RESOURCE_PREFIXES = [
480
+ "service.",
481
+ "telemetry.",
482
+ "host.",
483
+ "os.",
484
+ "process.",
485
+ "flare.framework.",
486
+ "flare.language."
487
+ ];
488
+ const RECORD_LEVEL_EXCEPTIONS = new Set(["process.uptime"]);
489
+ function partitionAttributes(attributes) {
490
+ const resource = {};
491
+ const record = {};
492
+ for (const [key, value] of Object.entries(attributes)) if (!RECORD_LEVEL_EXCEPTIONS.has(key) && RESOURCE_PREFIXES.some((prefix) => key.startsWith(prefix))) resource[key] = value;
493
+ else record[key] = value;
494
+ return {
495
+ resource,
496
+ record
497
+ };
498
+ }
195
499
 
196
500
  //#endregion
197
501
  //#region src/Scope.ts
@@ -406,6 +710,7 @@ var NullFileReader = class {
406
710
  const DEFAULT_SDK_NAME = "@flareapp/core";
407
711
  var Flare = class {
408
712
  inflight = /* @__PURE__ */ new Set();
713
+ _logger;
409
714
  _config = {
410
715
  key: null,
411
716
  version: "",
@@ -419,7 +724,13 @@ var Flare = class {
419
724
  replaceDefaultUrlDenylist: false,
420
725
  sampleRate: 1,
421
726
  beforeEvaluate: (error) => error,
422
- beforeSubmit: (report) => report
727
+ beforeSubmit: (report) => report,
728
+ enableLogs: false,
729
+ logsIngestUrl: "https://ingress.flareapp.io/v1/logs",
730
+ maxLogBufferSize: 100,
731
+ logFlushIntervalMs: 5e3,
732
+ logFlushMaxBytes: 8e5,
733
+ keepaliveMaxBytes: 6e4
423
734
  };
424
735
  sdkInfo = {
425
736
  name: DEFAULT_SDK_NAME,
@@ -438,11 +749,20 @@ var Flare = class {
438
749
  * single global scope; Node uses an AsyncLocalStorage-
439
750
  * backed provider so each request gets its own.
440
751
  */
441
- constructor(api = new Api(), contextCollector = () => ({}), fileReader = new NullFileReader(), scopeProvider = new GlobalScopeProvider()) {
752
+ constructor(api = new Api(), contextCollector = () => ({}), fileReader = new NullFileReader(), scopeProvider = new GlobalScopeProvider(), scheduler = new NoopFlushScheduler()) {
442
753
  this.api = api;
443
754
  this.contextCollector = contextCollector;
444
755
  this.fileReader = fileReader;
445
756
  this.scopeProvider = scopeProvider;
757
+ this._logger = new Logger({
758
+ api: this.api,
759
+ getConfig: () => this._config,
760
+ getSdkInfo: () => this.sdkInfo,
761
+ getFramework: () => this.framework,
762
+ buildLogAttributes: (userAttributes) => this.buildLogAttributes(userAttributes),
763
+ track: (p) => this.track(p),
764
+ scheduler
765
+ });
446
766
  }
447
767
  /**
448
768
  * Register an in-flight report so `flush()` can wait for it. Called by
@@ -581,6 +901,7 @@ var Flare = class {
581
901
  * again if you need to wait for those too.
582
902
  */
583
903
  flush(timeoutMs = 2e3) {
904
+ this._logger.flush();
584
905
  const pending = [...this.inflight];
585
906
  if (pending.length === 0) return Promise.resolve();
586
907
  return new Promise((resolve) => {
@@ -597,18 +918,25 @@ var Flare = class {
597
918
  get glows() {
598
919
  return this.scopeProvider.active().glows;
599
920
  }
921
+ get logger() {
922
+ return this._logger;
923
+ }
600
924
  light(key = KEY, debug) {
601
925
  this._config.key = key;
602
926
  if (debug !== void 0) this._config.debug = debug;
927
+ this._logger.flush();
603
928
  return this;
604
929
  }
605
930
  configure(config) {
931
+ const wasLogsEnabled = this._config.enableLogs;
606
932
  this._config = {
607
933
  ...this._config,
608
934
  ...config
609
935
  };
610
936
  if (config.sampleRate !== void 0) this._config.sampleRate = Math.max(0, Math.min(1, config.sampleRate));
611
937
  this._config.urlDenylist = resolveDenylist(config.urlDenylist, config.replaceDefaultUrlDenylist ?? this._config.replaceDefaultUrlDenylist);
938
+ if (wasLogsEnabled && this._config.enableLogs === false) this._logger.clear();
939
+ if (config.key !== void 0) this._logger.flush();
612
940
  return this;
613
941
  }
614
942
  test() {
@@ -729,8 +1057,7 @@ var Flare = class {
729
1057
  seenAtUnixNano
730
1058
  });
731
1059
  }
732
- buildReport(input) {
733
- const activeScope = this.scopeProvider.active();
1060
+ buildBaseAttributes() {
734
1061
  const baseAttributes = {
735
1062
  "telemetry.sdk.language": "javascript",
736
1063
  "telemetry.sdk.name": this.sdkInfo.name,
@@ -741,6 +1068,11 @@ var Flare = class {
741
1068
  if (this._config.version) baseAttributes["service.version"] = this._config.version;
742
1069
  if (this.framework?.name) baseAttributes["flare.framework.name"] = this.framework.name;
743
1070
  if (this.framework?.version) baseAttributes["flare.framework.version"] = this.framework.version;
1071
+ return baseAttributes;
1072
+ }
1073
+ assembleAttributes(collectorAttributes, extraAttributes, includeBase) {
1074
+ const activeScope = this.scopeProvider.active();
1075
+ const baseAttributes = includeBase ? this.buildBaseAttributes() : {};
744
1076
  const entryPoint = activeScope.entryPoint;
745
1077
  const entryPointOverrides = {};
746
1078
  if (entryPoint?.identifier !== void 0) entryPointOverrides["flare.entry_point.handler.identifier"] = entryPoint.identifier;
@@ -748,13 +1080,13 @@ var Flare = class {
748
1080
  if (entryPoint?.name !== void 0) entryPointOverrides["flare.entry_point.handler.name"] = entryPoint.name;
749
1081
  const attributes = {
750
1082
  ...baseAttributes,
751
- ...this.contextCollector(this._config),
1083
+ ...collectorAttributes,
752
1084
  ...entryPointOverrides,
753
1085
  ...activeScope.pendingAttributes,
754
- ...input.extraAttributes
1086
+ ...extraAttributes
755
1087
  };
756
1088
  const pendingCustom = activeScope.pendingAttributes["context.custom"];
757
- const extraCustom = input.extraAttributes["context.custom"];
1089
+ const extraCustom = extraAttributes["context.custom"];
758
1090
  if (pendingCustom && extraCustom && typeof pendingCustom === "object" && typeof extraCustom === "object" && !Array.isArray(pendingCustom) && !Array.isArray(extraCustom)) attributes["context.custom"] = {
759
1091
  ...pendingCustom,
760
1092
  ...extraCustom
@@ -763,6 +1095,18 @@ var Flare = class {
763
1095
  ...attributes["context.custom"] ?? {},
764
1096
  framework: this.framework.name.toLowerCase()
765
1097
  };
1098
+ return attributes;
1099
+ }
1100
+ buildLogAttributes(userAttributes) {
1101
+ const { resource, record: collectorRecord } = partitionAttributes(this.contextCollector(this._config));
1102
+ return {
1103
+ resource,
1104
+ record: this.assembleAttributes(collectorRecord, userAttributes, false)
1105
+ };
1106
+ }
1107
+ buildReport(input) {
1108
+ const activeScope = this.scopeProvider.active();
1109
+ const attributes = this.assembleAttributes(this.contextCollector(this._config), input.extraAttributes, true);
766
1110
  const report = {
767
1111
  exceptionClass: input.exceptionClass,
768
1112
  message: input.message,
@@ -790,6 +1134,8 @@ exports.Api = Api;
790
1134
  exports.DEFAULT_URL_DENYLIST = DEFAULT_URL_DENYLIST;
791
1135
  exports.Flare = Flare;
792
1136
  exports.GlobalScopeProvider = GlobalScopeProvider;
1137
+ exports.Logger = Logger;
1138
+ exports.NoopFlushScheduler = NoopFlushScheduler;
793
1139
  exports.NullFileReader = NullFileReader;
794
1140
  exports.Scope = Scope;
795
1141
  exports.assert = assert;
package/dist/index.d.cts CHANGED
@@ -16,6 +16,14 @@ type Config = {
16
16
  urlDenylist: RegExp;
17
17
  replaceDefaultUrlDenylist: boolean;
18
18
  sampleRate: number;
19
+ enableLogs: boolean;
20
+ logsIngestUrl: string;
21
+ minimumLogLevel?: MessageLevel;
22
+ serviceName?: string;
23
+ maxLogBufferSize: number;
24
+ logFlushIntervalMs: number;
25
+ logFlushMaxBytes: number;
26
+ keepaliveMaxBytes: number;
19
27
  beforeEvaluate: (error: Error) => Error | false | null | Promise<Error | false | null>;
20
28
  beforeSubmit: (report: Report) => Report | false | null | Promise<Report | false | null>;
21
29
  };
@@ -75,6 +83,64 @@ type Framework = {
75
83
  name: string;
76
84
  version?: string;
77
85
  };
86
+ type AnyValue = {
87
+ stringValue: string;
88
+ } | {
89
+ boolValue: boolean;
90
+ } | {
91
+ intValue: number;
92
+ } | {
93
+ doubleValue: number;
94
+ } | {
95
+ arrayValue: {
96
+ values: AnyValue[];
97
+ };
98
+ } | {
99
+ kvlistValue: {
100
+ values: KeyValue[];
101
+ };
102
+ };
103
+ type KeyValue = {
104
+ key: string;
105
+ value: AnyValue;
106
+ };
107
+ type OtelResource = {
108
+ attributes: KeyValue[];
109
+ droppedAttributesCount: number;
110
+ };
111
+ type OtelScope = {
112
+ name: string;
113
+ version: string;
114
+ attributes: KeyValue[];
115
+ droppedAttributesCount: number;
116
+ };
117
+ type OtelLogRecord = {
118
+ timeUnixNano: string;
119
+ observedTimeUnixNano: string;
120
+ severityNumber: number;
121
+ severityText: string;
122
+ body: AnyValue;
123
+ attributes: KeyValue[];
124
+ flags: number;
125
+ droppedAttributesCount: number;
126
+ };
127
+ type LogsEnvelope = {
128
+ resourceLogs: Array<{
129
+ resource: OtelResource;
130
+ scopeLogs: Array<{
131
+ scope: OtelScope;
132
+ logRecords: OtelLogRecord[];
133
+ }>;
134
+ }>;
135
+ };
136
+ type BufferedLog = {
137
+ timeUnixNano: string;
138
+ severityNumber: number;
139
+ severityText: string;
140
+ message: string;
141
+ recordAttributes: KeyValue[];
142
+ resourceAttributes: Attributes;
143
+ };
78
144
  //#endregion
79
145
  //#region src/util/assert.d.ts
80
146
  declare function assert(value: unknown, message: string, debug: boolean): boolean;
@@ -105,6 +171,68 @@ declare function redactUrlQuery(fullPath: string, denylist?: RegExp): string;
105
171
  //#region src/api/Api.d.ts
106
172
  declare class Api {
107
173
  report(report: Report, url: string, key: string | null, reportBrowserExtensionErrors: boolean, debug?: boolean): Promise<void>;
174
+ logs(envelope: LogsEnvelope, url: string, key: string | null, debug?: boolean, keepalive?: boolean): Promise<void>;
175
+ }
176
+ //#endregion
177
+ //#region src/logging/FlushScheduler.d.ts
178
+ type FlushFn = (opts?: {
179
+ keepalive?: boolean;
180
+ }) => void;
181
+ /**
182
+ * The seam through which a platform package wires the "drain on lifecycle end"
183
+ * trigger (browser unload, Node process exit). Core ships a no-op default; the
184
+ * count/weight/timer batching policy lives in `Logger` regardless.
185
+ */
186
+ interface FlushScheduler {
187
+ register(flush: FlushFn): void;
188
+ }
189
+ declare class NoopFlushScheduler implements FlushScheduler {
190
+ register(): void;
191
+ }
192
+ //#endregion
193
+ //#region src/logging/Logger.d.ts
194
+ type LoggerDeps = {
195
+ api: Api;
196
+ getConfig: () => Config;
197
+ getSdkInfo: () => SdkInfo;
198
+ getFramework: () => Framework | null;
199
+ buildLogAttributes: (userAttributes: Attributes) => {
200
+ record: Attributes;
201
+ resource: Attributes;
202
+ };
203
+ track: <T>(p: Promise<T>) => Promise<T>;
204
+ scheduler: FlushScheduler;
205
+ };
206
+ declare class Logger {
207
+ private deps;
208
+ private buffer;
209
+ private resourceAttributes;
210
+ private timer;
211
+ private timerActive;
212
+ constructor(deps: LoggerDeps);
213
+ debug(message: string, context?: Attributes, attributes?: Attributes): void;
214
+ info(message: string, context?: Attributes, attributes?: Attributes): void;
215
+ notice(message: string, context?: Attributes, attributes?: Attributes): void;
216
+ warning(message: string, context?: Attributes, attributes?: Attributes): void;
217
+ error(message: string, context?: Attributes, attributes?: Attributes): void;
218
+ critical(message: string, context?: Attributes, attributes?: Attributes): void;
219
+ alert(message: string, context?: Attributes, attributes?: Attributes): void;
220
+ emergency(message: string, context?: Attributes, attributes?: Attributes): void;
221
+ bufferLength(): number;
222
+ private record;
223
+ private evaluateTriggers;
224
+ private armTimer;
225
+ private trim;
226
+ flush(opts?: {
227
+ keepalive?: boolean;
228
+ }): void;
229
+ clear(): void;
230
+ private packForKeepalive;
231
+ private buildEnvelope;
232
+ private resourceForFlush;
233
+ private clearTimer;
234
+ private estimateBytes;
235
+ private bufferBytes;
108
236
  }
109
237
  //#endregion
110
238
  //#region src/Scope.d.ts
@@ -206,6 +334,7 @@ declare class Flare {
206
334
  private fileReader;
207
335
  private scopeProvider;
208
336
  private inflight;
337
+ private _logger;
209
338
  private _config;
210
339
  private sdkInfo;
211
340
  private framework;
@@ -221,7 +350,7 @@ declare class Flare {
221
350
  * single global scope; Node uses an AsyncLocalStorage-
222
351
  * backed provider so each request gets its own.
223
352
  */
224
- constructor(api?: Api, contextCollector?: ContextCollector, fileReader?: FileReader, scopeProvider?: ScopeProvider);
353
+ constructor(api?: Api, contextCollector?: ContextCollector, fileReader?: FileReader, scopeProvider?: ScopeProvider, scheduler?: FlushScheduler);
225
354
  /**
226
355
  * Register an in-flight report so `flush()` can wait for it. Called by
227
356
  * every public report entry point (`report`, `reportSilently`,
@@ -356,6 +485,7 @@ declare class Flare {
356
485
  flush(timeoutMs?: number): Promise<void>;
357
486
  get config(): Readonly<Config>;
358
487
  get glows(): readonly Glow[];
488
+ get logger(): Logger;
359
489
  light(key?: string, debug?: boolean): this;
360
490
  configure(config: Partial<Config>): this;
361
491
  test(): Promise<void>;
@@ -375,6 +505,9 @@ declare class Flare {
375
505
  reportMessage(message: string, level?: MessageLevel, attributes?: Attributes): Promise<void>;
376
506
  private reportMessageInternal;
377
507
  createReportFromError(error: Error, attributes?: Attributes, seenAtUnixNano?: number): Promise<Report | false>;
508
+ private buildBaseAttributes;
509
+ private assembleAttributes;
510
+ private buildLogAttributes;
378
511
  private buildReport;
379
512
  sendReport(report: Report): Promise<void>;
380
513
  }
@@ -408,4 +541,4 @@ declare class NullFileReader implements FileReader {
408
541
  //#region src/stacktrace/createStackTrace.d.ts
409
542
  declare function createStackTrace(error: Error, debug: boolean, fileReader: FileReader): Promise<Array<StackFrame>>;
410
543
  //#endregion
411
- export { Api, type AttributeValue, type Attributes, type Config, type ContextCollector, DEFAULT_URL_DENYLIST, type EntryPointHandler, type FileReader, Flare, type Framework, GlobalScopeProvider, type Glow, type MessageLevel, NullFileReader, type OverriddenGrouping, type Report, Scope, type ScopeProvider, type SdkInfo, type SpanEvent, type StackFrame, assert, assertKey, convertToError, createStackTrace, extractCode, flatJsonStringify, getCodeSnippet, glowsToEvents, now, readLinesFromFile, redactUrlQuery, resolveDenylist };
544
+ export { type AnyValue, Api, type AttributeValue, type Attributes, type BufferedLog, type Config, type ContextCollector, DEFAULT_URL_DENYLIST, type EntryPointHandler, type FileReader, Flare, type FlushFn, type FlushScheduler, type Framework, GlobalScopeProvider, type Glow, type KeyValue, Logger, type LoggerDeps, type LogsEnvelope, type MessageLevel, NoopFlushScheduler, NullFileReader, type OtelLogRecord, type OverriddenGrouping, type Report, Scope, type ScopeProvider, type SdkInfo, type SpanEvent, type StackFrame, assert, assertKey, convertToError, createStackTrace, extractCode, flatJsonStringify, getCodeSnippet, glowsToEvents, now, readLinesFromFile, redactUrlQuery, resolveDenylist };
package/dist/index.d.mts CHANGED
@@ -16,6 +16,14 @@ type Config = {
16
16
  urlDenylist: RegExp;
17
17
  replaceDefaultUrlDenylist: boolean;
18
18
  sampleRate: number;
19
+ enableLogs: boolean;
20
+ logsIngestUrl: string;
21
+ minimumLogLevel?: MessageLevel;
22
+ serviceName?: string;
23
+ maxLogBufferSize: number;
24
+ logFlushIntervalMs: number;
25
+ logFlushMaxBytes: number;
26
+ keepaliveMaxBytes: number;
19
27
  beforeEvaluate: (error: Error) => Error | false | null | Promise<Error | false | null>;
20
28
  beforeSubmit: (report: Report) => Report | false | null | Promise<Report | false | null>;
21
29
  };
@@ -75,6 +83,64 @@ type Framework = {
75
83
  name: string;
76
84
  version?: string;
77
85
  };
86
+ type AnyValue = {
87
+ stringValue: string;
88
+ } | {
89
+ boolValue: boolean;
90
+ } | {
91
+ intValue: number;
92
+ } | {
93
+ doubleValue: number;
94
+ } | {
95
+ arrayValue: {
96
+ values: AnyValue[];
97
+ };
98
+ } | {
99
+ kvlistValue: {
100
+ values: KeyValue[];
101
+ };
102
+ };
103
+ type KeyValue = {
104
+ key: string;
105
+ value: AnyValue;
106
+ };
107
+ type OtelResource = {
108
+ attributes: KeyValue[];
109
+ droppedAttributesCount: number;
110
+ };
111
+ type OtelScope = {
112
+ name: string;
113
+ version: string;
114
+ attributes: KeyValue[];
115
+ droppedAttributesCount: number;
116
+ };
117
+ type OtelLogRecord = {
118
+ timeUnixNano: string;
119
+ observedTimeUnixNano: string;
120
+ severityNumber: number;
121
+ severityText: string;
122
+ body: AnyValue;
123
+ attributes: KeyValue[];
124
+ flags: number;
125
+ droppedAttributesCount: number;
126
+ };
127
+ type LogsEnvelope = {
128
+ resourceLogs: Array<{
129
+ resource: OtelResource;
130
+ scopeLogs: Array<{
131
+ scope: OtelScope;
132
+ logRecords: OtelLogRecord[];
133
+ }>;
134
+ }>;
135
+ };
136
+ type BufferedLog = {
137
+ timeUnixNano: string;
138
+ severityNumber: number;
139
+ severityText: string;
140
+ message: string;
141
+ recordAttributes: KeyValue[];
142
+ resourceAttributes: Attributes;
143
+ };
78
144
  //#endregion
79
145
  //#region src/util/assert.d.ts
80
146
  declare function assert(value: unknown, message: string, debug: boolean): boolean;
@@ -105,6 +171,68 @@ declare function redactUrlQuery(fullPath: string, denylist?: RegExp): string;
105
171
  //#region src/api/Api.d.ts
106
172
  declare class Api {
107
173
  report(report: Report, url: string, key: string | null, reportBrowserExtensionErrors: boolean, debug?: boolean): Promise<void>;
174
+ logs(envelope: LogsEnvelope, url: string, key: string | null, debug?: boolean, keepalive?: boolean): Promise<void>;
175
+ }
176
+ //#endregion
177
+ //#region src/logging/FlushScheduler.d.ts
178
+ type FlushFn = (opts?: {
179
+ keepalive?: boolean;
180
+ }) => void;
181
+ /**
182
+ * The seam through which a platform package wires the "drain on lifecycle end"
183
+ * trigger (browser unload, Node process exit). Core ships a no-op default; the
184
+ * count/weight/timer batching policy lives in `Logger` regardless.
185
+ */
186
+ interface FlushScheduler {
187
+ register(flush: FlushFn): void;
188
+ }
189
+ declare class NoopFlushScheduler implements FlushScheduler {
190
+ register(): void;
191
+ }
192
+ //#endregion
193
+ //#region src/logging/Logger.d.ts
194
+ type LoggerDeps = {
195
+ api: Api;
196
+ getConfig: () => Config;
197
+ getSdkInfo: () => SdkInfo;
198
+ getFramework: () => Framework | null;
199
+ buildLogAttributes: (userAttributes: Attributes) => {
200
+ record: Attributes;
201
+ resource: Attributes;
202
+ };
203
+ track: <T>(p: Promise<T>) => Promise<T>;
204
+ scheduler: FlushScheduler;
205
+ };
206
+ declare class Logger {
207
+ private deps;
208
+ private buffer;
209
+ private resourceAttributes;
210
+ private timer;
211
+ private timerActive;
212
+ constructor(deps: LoggerDeps);
213
+ debug(message: string, context?: Attributes, attributes?: Attributes): void;
214
+ info(message: string, context?: Attributes, attributes?: Attributes): void;
215
+ notice(message: string, context?: Attributes, attributes?: Attributes): void;
216
+ warning(message: string, context?: Attributes, attributes?: Attributes): void;
217
+ error(message: string, context?: Attributes, attributes?: Attributes): void;
218
+ critical(message: string, context?: Attributes, attributes?: Attributes): void;
219
+ alert(message: string, context?: Attributes, attributes?: Attributes): void;
220
+ emergency(message: string, context?: Attributes, attributes?: Attributes): void;
221
+ bufferLength(): number;
222
+ private record;
223
+ private evaluateTriggers;
224
+ private armTimer;
225
+ private trim;
226
+ flush(opts?: {
227
+ keepalive?: boolean;
228
+ }): void;
229
+ clear(): void;
230
+ private packForKeepalive;
231
+ private buildEnvelope;
232
+ private resourceForFlush;
233
+ private clearTimer;
234
+ private estimateBytes;
235
+ private bufferBytes;
108
236
  }
109
237
  //#endregion
110
238
  //#region src/Scope.d.ts
@@ -206,6 +334,7 @@ declare class Flare {
206
334
  private fileReader;
207
335
  private scopeProvider;
208
336
  private inflight;
337
+ private _logger;
209
338
  private _config;
210
339
  private sdkInfo;
211
340
  private framework;
@@ -221,7 +350,7 @@ declare class Flare {
221
350
  * single global scope; Node uses an AsyncLocalStorage-
222
351
  * backed provider so each request gets its own.
223
352
  */
224
- constructor(api?: Api, contextCollector?: ContextCollector, fileReader?: FileReader, scopeProvider?: ScopeProvider);
353
+ constructor(api?: Api, contextCollector?: ContextCollector, fileReader?: FileReader, scopeProvider?: ScopeProvider, scheduler?: FlushScheduler);
225
354
  /**
226
355
  * Register an in-flight report so `flush()` can wait for it. Called by
227
356
  * every public report entry point (`report`, `reportSilently`,
@@ -356,6 +485,7 @@ declare class Flare {
356
485
  flush(timeoutMs?: number): Promise<void>;
357
486
  get config(): Readonly<Config>;
358
487
  get glows(): readonly Glow[];
488
+ get logger(): Logger;
359
489
  light(key?: string, debug?: boolean): this;
360
490
  configure(config: Partial<Config>): this;
361
491
  test(): Promise<void>;
@@ -375,6 +505,9 @@ declare class Flare {
375
505
  reportMessage(message: string, level?: MessageLevel, attributes?: Attributes): Promise<void>;
376
506
  private reportMessageInternal;
377
507
  createReportFromError(error: Error, attributes?: Attributes, seenAtUnixNano?: number): Promise<Report | false>;
508
+ private buildBaseAttributes;
509
+ private assembleAttributes;
510
+ private buildLogAttributes;
378
511
  private buildReport;
379
512
  sendReport(report: Report): Promise<void>;
380
513
  }
@@ -408,4 +541,4 @@ declare class NullFileReader implements FileReader {
408
541
  //#region src/stacktrace/createStackTrace.d.ts
409
542
  declare function createStackTrace(error: Error, debug: boolean, fileReader: FileReader): Promise<Array<StackFrame>>;
410
543
  //#endregion
411
- export { Api, type AttributeValue, type Attributes, type Config, type ContextCollector, DEFAULT_URL_DENYLIST, type EntryPointHandler, type FileReader, Flare, type Framework, GlobalScopeProvider, type Glow, type MessageLevel, NullFileReader, type OverriddenGrouping, type Report, Scope, type ScopeProvider, type SdkInfo, type SpanEvent, type StackFrame, assert, assertKey, convertToError, createStackTrace, extractCode, flatJsonStringify, getCodeSnippet, glowsToEvents, now, readLinesFromFile, redactUrlQuery, resolveDenylist };
544
+ export { type AnyValue, Api, type AttributeValue, type Attributes, type BufferedLog, type Config, type ContextCollector, DEFAULT_URL_DENYLIST, type EntryPointHandler, type FileReader, Flare, type FlushFn, type FlushScheduler, type Framework, GlobalScopeProvider, type Glow, type KeyValue, Logger, type LoggerDeps, type LogsEnvelope, type MessageLevel, NoopFlushScheduler, NullFileReader, type OtelLogRecord, type OverriddenGrouping, type Report, Scope, type ScopeProvider, type SdkInfo, type SpanEvent, type StackFrame, assert, assertKey, convertToError, createStackTrace, extractCode, flatJsonStringify, getCodeSnippet, glowsToEvents, now, readLinesFromFile, redactUrlQuery, resolveDenylist };
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import ErrorStackParser from "error-stack-parser";
2
2
 
3
3
  //#region src/env/index.ts
4
- const CLIENT_VERSION = typeof process !== "undefined" && true ? "2.2.1" : "?";
4
+ const CLIENT_VERSION = typeof process !== "undefined" && true ? "2.3.0" : "?";
5
5
  const KEY = typeof FLARE_JS_KEY === "undefined" ? "" : FLARE_JS_KEY;
6
6
  const SOURCEMAP_VERSION = typeof FLARE_SOURCEMAP_VERSION === "undefined" ? "" : FLARE_SOURCEMAP_VERSION;
7
7
 
@@ -162,7 +162,311 @@ var Api = class {
162
162
  if (debug) console.error(error);
163
163
  });
164
164
  }
165
+ logs(envelope, url, key, debug = false, keepalive = false) {
166
+ return fetch(url, {
167
+ method: "POST",
168
+ keepalive,
169
+ headers: {
170
+ "Accept": "application/json",
171
+ "Content-Type": "application/json",
172
+ "x-api-token": key ?? ""
173
+ },
174
+ body: flatJsonStringify(envelope)
175
+ }).then((response) => {
176
+ if (debug && response.status !== 201) console.error(`Received response with status ${response.status} from Flare logs`);
177
+ }, (error) => {
178
+ if (debug) console.error(error);
179
+ });
180
+ }
181
+ };
182
+
183
+ //#endregion
184
+ //#region src/logging/otel.ts
185
+ function valueToOpenTelemetry(value, inPath = /* @__PURE__ */ new WeakSet()) {
186
+ if (typeof value === "string") return { stringValue: value };
187
+ if (typeof value === "boolean") return { boolValue: value };
188
+ if (typeof value === "number") {
189
+ if (!Number.isFinite(value)) return null;
190
+ return Number.isInteger(value) ? { intValue: value } : { doubleValue: value };
191
+ }
192
+ if (value === null || value === void 0) return null;
193
+ if (Array.isArray(value)) {
194
+ if (inPath.has(value)) return { stringValue: "[Circular]" };
195
+ inPath.add(value);
196
+ const values = [];
197
+ for (const item of value) {
198
+ const mapped = valueToOpenTelemetry(item, inPath);
199
+ if (mapped !== null) values.push(mapped);
200
+ }
201
+ inPath.delete(value);
202
+ return { arrayValue: { values } };
203
+ }
204
+ if (typeof value === "object") {
205
+ if (inPath.has(value)) return { stringValue: "[Circular]" };
206
+ inPath.add(value);
207
+ const values = [];
208
+ for (const [key, item] of Object.entries(value)) {
209
+ const mapped = valueToOpenTelemetry(item, inPath);
210
+ if (mapped !== null) values.push({
211
+ key,
212
+ value: mapped
213
+ });
214
+ }
215
+ inPath.delete(value);
216
+ return { kvlistValue: { values } };
217
+ }
218
+ return null;
219
+ }
220
+ function attributesToOpenTelemetry(attributes) {
221
+ const out = [];
222
+ for (const [key, value] of Object.entries(attributes)) {
223
+ const mapped = valueToOpenTelemetry(value);
224
+ if (mapped !== null) out.push({
225
+ key,
226
+ value: mapped
227
+ });
228
+ }
229
+ return out;
230
+ }
231
+
232
+ //#endregion
233
+ //#region src/logging/envelope.ts
234
+ function buildLogsEnvelope(records, resourceAttributes, scopeName, scopeVersion) {
235
+ return { resourceLogs: [{
236
+ resource: {
237
+ attributes: attributesToOpenTelemetry(resourceAttributes),
238
+ droppedAttributesCount: 0
239
+ },
240
+ scopeLogs: [{
241
+ scope: {
242
+ name: scopeName,
243
+ version: scopeVersion,
244
+ attributes: [],
245
+ droppedAttributesCount: 0
246
+ },
247
+ logRecords: records.map((record) => ({
248
+ timeUnixNano: record.timeUnixNano,
249
+ observedTimeUnixNano: record.timeUnixNano,
250
+ severityNumber: record.severityNumber,
251
+ severityText: record.severityText,
252
+ body: { stringValue: record.message },
253
+ attributes: record.recordAttributes,
254
+ flags: 0,
255
+ droppedAttributesCount: 0
256
+ }))
257
+ }]
258
+ }] };
259
+ }
260
+
261
+ //#endregion
262
+ //#region src/logging/severity.ts
263
+ const SEVERITY_NUMBERS = {
264
+ debug: 5,
265
+ info: 9,
266
+ notice: 10,
267
+ warning: 13,
268
+ error: 17,
269
+ critical: 18,
270
+ alert: 19,
271
+ emergency: 21
165
272
  };
273
+ function severityNumber(level) {
274
+ return SEVERITY_NUMBERS[level];
275
+ }
276
+ function severityText(level) {
277
+ return level.toUpperCase();
278
+ }
279
+ function isAtOrAboveMinimum(level, minimum) {
280
+ return severityNumber(level) >= severityNumber(minimum);
281
+ }
282
+
283
+ //#endregion
284
+ //#region src/logging/Logger.ts
285
+ var Logger = class {
286
+ buffer = [];
287
+ resourceAttributes = {};
288
+ timer;
289
+ timerActive = false;
290
+ constructor(deps) {
291
+ this.deps = deps;
292
+ const flush = (opts) => this.flush(opts);
293
+ this.deps.scheduler.register(flush);
294
+ }
295
+ debug(message, context = {}, attributes = {}) {
296
+ this.record("debug", message, context, attributes);
297
+ }
298
+ info(message, context = {}, attributes = {}) {
299
+ this.record("info", message, context, attributes);
300
+ }
301
+ notice(message, context = {}, attributes = {}) {
302
+ this.record("notice", message, context, attributes);
303
+ }
304
+ warning(message, context = {}, attributes = {}) {
305
+ this.record("warning", message, context, attributes);
306
+ }
307
+ error(message, context = {}, attributes = {}) {
308
+ this.record("error", message, context, attributes);
309
+ }
310
+ critical(message, context = {}, attributes = {}) {
311
+ this.record("critical", message, context, attributes);
312
+ }
313
+ alert(message, context = {}, attributes = {}) {
314
+ this.record("alert", message, context, attributes);
315
+ }
316
+ emergency(message, context = {}, attributes = {}) {
317
+ this.record("emergency", message, context, attributes);
318
+ }
319
+ bufferLength() {
320
+ return this.buffer.length;
321
+ }
322
+ record(level, message, context, attributes) {
323
+ const config = this.deps.getConfig();
324
+ if (!config.enableLogs) return;
325
+ if (config.minimumLogLevel && !isAtOrAboveMinimum(level, config.minimumLogLevel)) return;
326
+ const userAttributes = {
327
+ "log.context": context,
328
+ ...attributes
329
+ };
330
+ const { record, resource } = this.deps.buildLogAttributes(userAttributes);
331
+ const buffered = {
332
+ timeUnixNano: String(Date.now()) + "000000",
333
+ severityNumber: severityNumber(level),
334
+ severityText: severityText(level),
335
+ message,
336
+ recordAttributes: attributesToOpenTelemetry(record),
337
+ resourceAttributes: resource
338
+ };
339
+ if (this.estimateBytes(buffered) > config.logFlushMaxBytes) {
340
+ if (config.debug) console.error("Flare: dropping oversized log record");
341
+ return;
342
+ }
343
+ this.buffer.push(buffered);
344
+ this.resourceAttributes = resource;
345
+ this.evaluateTriggers(config);
346
+ this.trim(config);
347
+ }
348
+ evaluateTriggers(config) {
349
+ if (this.buffer.length >= config.maxLogBufferSize) {
350
+ this.flush();
351
+ return;
352
+ }
353
+ if (this.bufferBytes() >= config.logFlushMaxBytes) {
354
+ this.flush();
355
+ return;
356
+ }
357
+ this.armTimer(config);
358
+ }
359
+ armTimer(config) {
360
+ if (this.timerActive) return;
361
+ this.timerActive = true;
362
+ this.timer = setTimeout(() => this.flush(), config.logFlushIntervalMs);
363
+ this.timer.unref?.();
364
+ }
365
+ trim(config) {
366
+ if (this.buffer.length > config.maxLogBufferSize) this.buffer = this.buffer.slice(this.buffer.length - config.maxLogBufferSize);
367
+ while (this.buffer.length > 1 && this.bufferBytes() > config.logFlushMaxBytes) this.buffer.shift();
368
+ }
369
+ flush(opts) {
370
+ const config = this.deps.getConfig();
371
+ if (!config.enableLogs) return;
372
+ if (this.buffer.length === 0) return;
373
+ if (!assertKey(config.key, config.debug)) {
374
+ this.clearTimer();
375
+ return;
376
+ }
377
+ this.clearTimer();
378
+ let records;
379
+ if (opts?.keepalive) {
380
+ records = this.packForKeepalive(config);
381
+ this.buffer = this.buffer.filter((log) => !records.includes(log));
382
+ if (this.buffer.length > 0) this.armTimer(config);
383
+ } else {
384
+ records = this.buffer;
385
+ this.buffer = [];
386
+ }
387
+ if (records.length === 0) return;
388
+ this.deps.track(this.deps.api.logs(this.buildEnvelope(records), config.logsIngestUrl, config.key, config.debug, !!opts?.keepalive));
389
+ }
390
+ clear() {
391
+ this.buffer = [];
392
+ this.clearTimer();
393
+ }
394
+ packForKeepalive(config) {
395
+ let selected = [];
396
+ for (let i = this.buffer.length - 1; i >= 0; i--) {
397
+ const trial = [this.buffer[i], ...selected];
398
+ if (new TextEncoder().encode(flatJsonStringify(this.buildEnvelope(trial))).length <= config.keepaliveMaxBytes) selected = trial;
399
+ else if (config.debug) console.error("Flare: dropping log record from keepalive envelope (over budget)");
400
+ }
401
+ return selected;
402
+ }
403
+ buildEnvelope(records) {
404
+ const sdk = this.deps.getSdkInfo();
405
+ return buildLogsEnvelope(records, this.resourceForFlush(), sdk.name, sdk.version);
406
+ }
407
+ resourceForFlush() {
408
+ const config = this.deps.getConfig();
409
+ const sdk = this.deps.getSdkInfo();
410
+ const framework = this.deps.getFramework();
411
+ const identity = {
412
+ "telemetry.sdk.language": "javascript",
413
+ "telemetry.sdk.name": sdk.name,
414
+ "telemetry.sdk.version": sdk.version,
415
+ "flare.language.name": "javascript"
416
+ };
417
+ if (config.serviceName) identity["service.name"] = config.serviceName;
418
+ if (config.version) identity["service.version"] = config.version;
419
+ if (config.stage) identity["service.stage"] = config.stage;
420
+ if (framework?.name) identity["flare.framework.name"] = framework.name;
421
+ if (framework?.version) identity["flare.framework.version"] = framework.version;
422
+ return {
423
+ ...this.resourceAttributes,
424
+ ...identity
425
+ };
426
+ }
427
+ clearTimer() {
428
+ if (this.timer) {
429
+ clearTimeout(this.timer);
430
+ this.timer = void 0;
431
+ }
432
+ this.timerActive = false;
433
+ }
434
+ estimateBytes(log) {
435
+ return flatJsonStringify(log).length;
436
+ }
437
+ bufferBytes() {
438
+ return this.buffer.reduce((sum, log) => sum + this.estimateBytes(log), 0);
439
+ }
440
+ };
441
+
442
+ //#endregion
443
+ //#region src/logging/FlushScheduler.ts
444
+ var NoopFlushScheduler = class {
445
+ register() {}
446
+ };
447
+
448
+ //#endregion
449
+ //#region src/logging/partition.ts
450
+ const RESOURCE_PREFIXES = [
451
+ "service.",
452
+ "telemetry.",
453
+ "host.",
454
+ "os.",
455
+ "process.",
456
+ "flare.framework.",
457
+ "flare.language."
458
+ ];
459
+ const RECORD_LEVEL_EXCEPTIONS = new Set(["process.uptime"]);
460
+ function partitionAttributes(attributes) {
461
+ const resource = {};
462
+ const record = {};
463
+ for (const [key, value] of Object.entries(attributes)) if (!RECORD_LEVEL_EXCEPTIONS.has(key) && RESOURCE_PREFIXES.some((prefix) => key.startsWith(prefix))) resource[key] = value;
464
+ else record[key] = value;
465
+ return {
466
+ resource,
467
+ record
468
+ };
469
+ }
166
470
 
167
471
  //#endregion
168
472
  //#region src/Scope.ts
@@ -377,6 +681,7 @@ var NullFileReader = class {
377
681
  const DEFAULT_SDK_NAME = "@flareapp/core";
378
682
  var Flare = class {
379
683
  inflight = /* @__PURE__ */ new Set();
684
+ _logger;
380
685
  _config = {
381
686
  key: null,
382
687
  version: "",
@@ -390,7 +695,13 @@ var Flare = class {
390
695
  replaceDefaultUrlDenylist: false,
391
696
  sampleRate: 1,
392
697
  beforeEvaluate: (error) => error,
393
- beforeSubmit: (report) => report
698
+ beforeSubmit: (report) => report,
699
+ enableLogs: false,
700
+ logsIngestUrl: "https://ingress.flareapp.io/v1/logs",
701
+ maxLogBufferSize: 100,
702
+ logFlushIntervalMs: 5e3,
703
+ logFlushMaxBytes: 8e5,
704
+ keepaliveMaxBytes: 6e4
394
705
  };
395
706
  sdkInfo = {
396
707
  name: DEFAULT_SDK_NAME,
@@ -409,11 +720,20 @@ var Flare = class {
409
720
  * single global scope; Node uses an AsyncLocalStorage-
410
721
  * backed provider so each request gets its own.
411
722
  */
412
- constructor(api = new Api(), contextCollector = () => ({}), fileReader = new NullFileReader(), scopeProvider = new GlobalScopeProvider()) {
723
+ constructor(api = new Api(), contextCollector = () => ({}), fileReader = new NullFileReader(), scopeProvider = new GlobalScopeProvider(), scheduler = new NoopFlushScheduler()) {
413
724
  this.api = api;
414
725
  this.contextCollector = contextCollector;
415
726
  this.fileReader = fileReader;
416
727
  this.scopeProvider = scopeProvider;
728
+ this._logger = new Logger({
729
+ api: this.api,
730
+ getConfig: () => this._config,
731
+ getSdkInfo: () => this.sdkInfo,
732
+ getFramework: () => this.framework,
733
+ buildLogAttributes: (userAttributes) => this.buildLogAttributes(userAttributes),
734
+ track: (p) => this.track(p),
735
+ scheduler
736
+ });
417
737
  }
418
738
  /**
419
739
  * Register an in-flight report so `flush()` can wait for it. Called by
@@ -552,6 +872,7 @@ var Flare = class {
552
872
  * again if you need to wait for those too.
553
873
  */
554
874
  flush(timeoutMs = 2e3) {
875
+ this._logger.flush();
555
876
  const pending = [...this.inflight];
556
877
  if (pending.length === 0) return Promise.resolve();
557
878
  return new Promise((resolve) => {
@@ -568,18 +889,25 @@ var Flare = class {
568
889
  get glows() {
569
890
  return this.scopeProvider.active().glows;
570
891
  }
892
+ get logger() {
893
+ return this._logger;
894
+ }
571
895
  light(key = KEY, debug) {
572
896
  this._config.key = key;
573
897
  if (debug !== void 0) this._config.debug = debug;
898
+ this._logger.flush();
574
899
  return this;
575
900
  }
576
901
  configure(config) {
902
+ const wasLogsEnabled = this._config.enableLogs;
577
903
  this._config = {
578
904
  ...this._config,
579
905
  ...config
580
906
  };
581
907
  if (config.sampleRate !== void 0) this._config.sampleRate = Math.max(0, Math.min(1, config.sampleRate));
582
908
  this._config.urlDenylist = resolveDenylist(config.urlDenylist, config.replaceDefaultUrlDenylist ?? this._config.replaceDefaultUrlDenylist);
909
+ if (wasLogsEnabled && this._config.enableLogs === false) this._logger.clear();
910
+ if (config.key !== void 0) this._logger.flush();
583
911
  return this;
584
912
  }
585
913
  test() {
@@ -700,8 +1028,7 @@ var Flare = class {
700
1028
  seenAtUnixNano
701
1029
  });
702
1030
  }
703
- buildReport(input) {
704
- const activeScope = this.scopeProvider.active();
1031
+ buildBaseAttributes() {
705
1032
  const baseAttributes = {
706
1033
  "telemetry.sdk.language": "javascript",
707
1034
  "telemetry.sdk.name": this.sdkInfo.name,
@@ -712,6 +1039,11 @@ var Flare = class {
712
1039
  if (this._config.version) baseAttributes["service.version"] = this._config.version;
713
1040
  if (this.framework?.name) baseAttributes["flare.framework.name"] = this.framework.name;
714
1041
  if (this.framework?.version) baseAttributes["flare.framework.version"] = this.framework.version;
1042
+ return baseAttributes;
1043
+ }
1044
+ assembleAttributes(collectorAttributes, extraAttributes, includeBase) {
1045
+ const activeScope = this.scopeProvider.active();
1046
+ const baseAttributes = includeBase ? this.buildBaseAttributes() : {};
715
1047
  const entryPoint = activeScope.entryPoint;
716
1048
  const entryPointOverrides = {};
717
1049
  if (entryPoint?.identifier !== void 0) entryPointOverrides["flare.entry_point.handler.identifier"] = entryPoint.identifier;
@@ -719,13 +1051,13 @@ var Flare = class {
719
1051
  if (entryPoint?.name !== void 0) entryPointOverrides["flare.entry_point.handler.name"] = entryPoint.name;
720
1052
  const attributes = {
721
1053
  ...baseAttributes,
722
- ...this.contextCollector(this._config),
1054
+ ...collectorAttributes,
723
1055
  ...entryPointOverrides,
724
1056
  ...activeScope.pendingAttributes,
725
- ...input.extraAttributes
1057
+ ...extraAttributes
726
1058
  };
727
1059
  const pendingCustom = activeScope.pendingAttributes["context.custom"];
728
- const extraCustom = input.extraAttributes["context.custom"];
1060
+ const extraCustom = extraAttributes["context.custom"];
729
1061
  if (pendingCustom && extraCustom && typeof pendingCustom === "object" && typeof extraCustom === "object" && !Array.isArray(pendingCustom) && !Array.isArray(extraCustom)) attributes["context.custom"] = {
730
1062
  ...pendingCustom,
731
1063
  ...extraCustom
@@ -734,6 +1066,18 @@ var Flare = class {
734
1066
  ...attributes["context.custom"] ?? {},
735
1067
  framework: this.framework.name.toLowerCase()
736
1068
  };
1069
+ return attributes;
1070
+ }
1071
+ buildLogAttributes(userAttributes) {
1072
+ const { resource, record: collectorRecord } = partitionAttributes(this.contextCollector(this._config));
1073
+ return {
1074
+ resource,
1075
+ record: this.assembleAttributes(collectorRecord, userAttributes, false)
1076
+ };
1077
+ }
1078
+ buildReport(input) {
1079
+ const activeScope = this.scopeProvider.active();
1080
+ const attributes = this.assembleAttributes(this.contextCollector(this._config), input.extraAttributes, true);
737
1081
  const report = {
738
1082
  exceptionClass: input.exceptionClass,
739
1083
  message: input.message,
@@ -757,4 +1101,4 @@ var Flare = class {
757
1101
  };
758
1102
 
759
1103
  //#endregion
760
- export { Api, DEFAULT_URL_DENYLIST, Flare, GlobalScopeProvider, NullFileReader, Scope, assert, assertKey, convertToError, createStackTrace, extractCode, flatJsonStringify, getCodeSnippet, glowsToEvents, now, readLinesFromFile, redactUrlQuery, resolveDenylist };
1104
+ export { Api, DEFAULT_URL_DENYLIST, Flare, GlobalScopeProvider, Logger, NoopFlushScheduler, NullFileReader, Scope, assert, assertKey, convertToError, createStackTrace, extractCode, flatJsonStringify, getCodeSnippet, glowsToEvents, now, readLinesFromFile, redactUrlQuery, resolveDenylist };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flareapp/core",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Environment-agnostic core for the Flare JS SDK",
5
5
  "homepage": "https://flareapp.io",
6
6
  "bugs": {