@sincpro/mobile 0.3.1 → 0.4.2
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 +234 -2
- package/dist/entrypoints/cron/EBuiltinCron.d.ts +4 -0
- package/dist/entrypoints/cron/EBuiltinCron.js +5 -0
- package/dist/entrypoints/cron/checkNetworkStatus.cron.js +2 -1
- package/dist/entrypoints/db/migrations.d.ts +3 -1
- package/dist/entrypoints/db/migrations.js +47 -0
- package/dist/entrypoints/queue/QueueProcessor.js +1 -25
- package/dist/entrypoints/queue/subscriber_filter.d.ts +9 -0
- package/dist/entrypoints/queue/subscriber_filter.js +25 -0
- package/dist/entrypoints/ui/common_provider.js +2 -2
- package/dist/framework/base_module.d.ts +1 -0
- package/dist/framework/base_module.js +6 -1
- package/dist/framework/createApp.d.ts +14 -0
- package/dist/framework/createApp.js +6 -0
- package/dist/framework/kernel.d.ts +2 -1
- package/dist/framework/kernel.js +8 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/infrastructure/context_manager/context.d.ts +16 -0
- package/dist/infrastructure/context_manager/context.js +37 -0
- package/dist/infrastructure/context_manager/context_api.d.ts +28 -0
- package/dist/infrastructure/context_manager/context_api.js +55 -0
- package/dist/infrastructure/context_manager/context_key.d.ts +15 -0
- package/dist/infrastructure/context_manager/context_key.js +3 -0
- package/dist/infrastructure/context_manager/context_manager_facade.d.ts +107 -0
- package/dist/infrastructure/context_manager/context_manager_facade.js +113 -0
- package/dist/infrastructure/context_manager/decorators/propagate_context.d.ts +24 -0
- package/dist/infrastructure/context_manager/decorators/propagate_context.js +52 -0
- package/dist/infrastructure/context_manager/decorators/with_context.d.ts +34 -0
- package/dist/infrastructure/context_manager/decorators/with_context.js +44 -0
- package/dist/infrastructure/context_manager/index.d.ts +11 -0
- package/dist/infrastructure/context_manager/index.js +12 -0
- package/dist/infrastructure/context_manager/managed_context.d.ts +87 -0
- package/dist/infrastructure/context_manager/managed_context.js +144 -0
- package/dist/infrastructure/context_manager/stack_context_manager.d.ts +39 -0
- package/dist/infrastructure/context_manager/stack_context_manager.js +56 -0
- package/dist/infrastructure/database/connector.js +10 -1
- package/dist/infrastructure/interceptor/index.d.ts +4 -0
- package/dist/infrastructure/interceptor/index.js +2 -0
- package/dist/infrastructure/interceptor/intercept.d.ts +57 -0
- package/dist/infrastructure/interceptor/intercept.js +112 -0
- package/dist/infrastructure/interceptor/logging_hooks.d.ts +22 -0
- package/dist/infrastructure/interceptor/logging_hooks.js +27 -0
- package/dist/infrastructure/logger.d.ts +11 -16
- package/dist/infrastructure/logger.js +61 -18
- package/dist/infrastructure/telemetry/buffer_registry.d.ts +15 -0
- package/dist/infrastructure/telemetry/buffer_registry.js +26 -0
- package/dist/infrastructure/telemetry/connectivity_state.d.ts +20 -0
- package/dist/infrastructure/telemetry/connectivity_state.js +28 -0
- package/dist/infrastructure/telemetry/context_keys.d.ts +7 -0
- package/dist/infrastructure/telemetry/context_keys.js +7 -0
- package/dist/infrastructure/telemetry/fetch_with_timeout.d.ts +8 -0
- package/dist/infrastructure/telemetry/fetch_with_timeout.js +17 -0
- package/dist/infrastructure/telemetry/flush_telemetry.d.ts +40 -0
- package/dist/infrastructure/telemetry/flush_telemetry.js +72 -0
- package/dist/infrastructure/telemetry/index.d.ts +17 -0
- package/dist/infrastructure/telemetry/index.js +17 -0
- package/dist/infrastructure/telemetry/init_telemetry.d.ts +21 -0
- package/dist/infrastructure/telemetry/init_telemetry.js +85 -0
- package/dist/infrastructure/telemetry/logging/flush_job.d.ts +44 -0
- package/dist/infrastructure/telemetry/logging/flush_job.js +69 -0
- package/dist/infrastructure/telemetry/logging/log_queue_repository.d.ts +76 -0
- package/dist/infrastructure/telemetry/logging/log_queue_repository.js +147 -0
- package/dist/infrastructure/telemetry/logging/loki_client.d.ts +32 -0
- package/dist/infrastructure/telemetry/logging/loki_client.js +118 -0
- package/dist/infrastructure/telemetry/logging/loki_registry.d.ts +12 -0
- package/dist/infrastructure/telemetry/logging/loki_registry.js +17 -0
- package/dist/infrastructure/telemetry/telemetry_flush_worker.d.ts +35 -0
- package/dist/infrastructure/telemetry/telemetry_flush_worker.js +66 -0
- package/dist/infrastructure/telemetry/telemetry_signal.d.ts +18 -0
- package/dist/infrastructure/telemetry/telemetry_signal.js +33 -0
- package/dist/infrastructure/telemetry/tracing/active_span.d.ts +22 -0
- package/dist/infrastructure/telemetry/tracing/active_span.js +40 -0
- package/dist/infrastructure/telemetry/tracing/otlp_client.d.ts +13 -0
- package/dist/infrastructure/telemetry/tracing/otlp_client.js +34 -0
- package/dist/infrastructure/telemetry/tracing/otlp_serializer.d.ts +67 -0
- package/dist/infrastructure/telemetry/tracing/otlp_serializer.js +101 -0
- package/dist/infrastructure/telemetry/tracing/span_flush_job.d.ts +25 -0
- package/dist/infrastructure/telemetry/tracing/span_flush_job.js +42 -0
- package/dist/infrastructure/telemetry/tracing/span_queue_repository.d.ts +82 -0
- package/dist/infrastructure/telemetry/tracing/span_queue_repository.js +134 -0
- package/dist/infrastructure/telemetry/tracing/span_sampler.d.ts +40 -0
- package/dist/infrastructure/telemetry/tracing/span_sampler.js +75 -0
- package/dist/infrastructure/telemetry/tracing/span_serializer.d.ts +48 -0
- package/dist/infrastructure/telemetry/tracing/span_serializer.js +36 -0
- package/dist/infrastructure/telemetry/tracing/sqlite_span_exporter.d.ts +26 -0
- package/dist/infrastructure/telemetry/tracing/sqlite_span_exporter.js +47 -0
- package/dist/infrastructure/telemetry/tracing/tracer.d.ts +22 -0
- package/dist/infrastructure/telemetry/tracing/tracer.js +45 -0
- package/dist/infrastructure/telemetry/tracing/tracing_hooks.d.ts +10 -0
- package/dist/infrastructure/telemetry/tracing/tracing_hooks.js +59 -0
- package/dist/infrastructure/telemetry/tracing_facade.d.ts +50 -0
- package/dist/infrastructure/telemetry/tracing_facade.js +139 -0
- package/dist/infrastructure/telemetry/types.d.ts +120 -0
- package/dist/infrastructure/telemetry/types.js +1 -0
- package/dist/infrastructure/workers/CronWorker.d.ts +10 -2
- package/dist/infrastructure/workers/CronWorker.js +18 -5
- package/dist/infrastructure/workers/index.d.ts +1 -1
- package/dist/ui/components/molecules/TimeZoneSelectorModal.js +7 -1
- package/package.json +17 -11
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
function encodeValue(v) {
|
|
2
|
+
if (typeof v === "string")
|
|
3
|
+
return { stringValue: v };
|
|
4
|
+
// intValue is a string in OTLP/JSON (uint64 can exceed JS Number precision)
|
|
5
|
+
if (typeof v === "number" && Number.isInteger(v))
|
|
6
|
+
return { intValue: String(v) };
|
|
7
|
+
if (typeof v === "number")
|
|
8
|
+
return { doubleValue: v };
|
|
9
|
+
if (typeof v === "boolean")
|
|
10
|
+
return { boolValue: v };
|
|
11
|
+
return { stringValue: String(v) };
|
|
12
|
+
}
|
|
13
|
+
function encodeAttrs(json) {
|
|
14
|
+
try {
|
|
15
|
+
const obj = JSON.parse(json);
|
|
16
|
+
return Object.entries(obj).map(([key, value]) => ({ key, value: encodeValue(value) }));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function parseEvents(json) {
|
|
23
|
+
try {
|
|
24
|
+
const raw = JSON.parse(json);
|
|
25
|
+
return raw.map((e) => ({
|
|
26
|
+
name: e.name,
|
|
27
|
+
timeUnixNano: e.timeUnixNano,
|
|
28
|
+
attributes: Object.entries(e.attributes ?? {}).map(([key, value]) => ({
|
|
29
|
+
key,
|
|
30
|
+
value: encodeValue(value),
|
|
31
|
+
})),
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function parseLinks(json) {
|
|
39
|
+
try {
|
|
40
|
+
const raw = JSON.parse(json);
|
|
41
|
+
return raw.map((l) => ({
|
|
42
|
+
traceId: l.traceId,
|
|
43
|
+
spanId: l.spanId,
|
|
44
|
+
attributes: Object.entries(l.attributes ?? {}).map(([key, value]) => ({
|
|
45
|
+
key,
|
|
46
|
+
value: encodeValue(value),
|
|
47
|
+
})),
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Serializer
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
/**
|
|
58
|
+
* Converts a batch of SpanRows (from SQLite) into an OTLP/HTTP JSON payload.
|
|
59
|
+
*
|
|
60
|
+
* Grouping strategy: spans sharing the same `resource_attrs` JSON are placed
|
|
61
|
+
* under the same ResourceSpans entry. All spans are placed under a single
|
|
62
|
+
* scope named "@sincpro/mobile".
|
|
63
|
+
*
|
|
64
|
+
* traceId and spanId are hex strings — OTLP/JSON uses hex (not base64).
|
|
65
|
+
* Timestamps are nanosecond strings — already stored that way in SQLite.
|
|
66
|
+
*/
|
|
67
|
+
export function serializeToOtlp(spans) {
|
|
68
|
+
// Group by resource_attrs to minimize payload size
|
|
69
|
+
const byResource = new Map();
|
|
70
|
+
for (const span of spans) {
|
|
71
|
+
const key = span.resource_attrs;
|
|
72
|
+
const group = byResource.get(key) ?? [];
|
|
73
|
+
group.push(span);
|
|
74
|
+
byResource.set(key, group);
|
|
75
|
+
}
|
|
76
|
+
const resourceSpans = [];
|
|
77
|
+
for (const [resourceJson, group] of byResource) {
|
|
78
|
+
const otlpSpans = group.map((s) => {
|
|
79
|
+
const span = {
|
|
80
|
+
traceId: s.trace_id,
|
|
81
|
+
spanId: s.span_id,
|
|
82
|
+
name: s.name,
|
|
83
|
+
kind: s.kind,
|
|
84
|
+
startTimeUnixNano: s.start_time_unixnano,
|
|
85
|
+
endTimeUnixNano: s.end_time_unixnano,
|
|
86
|
+
attributes: encodeAttrs(s.attributes),
|
|
87
|
+
events: parseEvents(s.events ?? "[]"),
|
|
88
|
+
links: parseLinks(s.links ?? "[]"),
|
|
89
|
+
status: { code: s.status_code, message: s.status_message || undefined },
|
|
90
|
+
};
|
|
91
|
+
if (s.parent_span_id)
|
|
92
|
+
span.parentSpanId = s.parent_span_id;
|
|
93
|
+
return span;
|
|
94
|
+
});
|
|
95
|
+
resourceSpans.push({
|
|
96
|
+
resource: { attributes: encodeAttrs(resourceJson) },
|
|
97
|
+
scopeSpans: [{ scope: { name: "@sincpro/mobile" }, spans: otlpSpans }],
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return { resourceSpans };
|
|
101
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { FlushResult } from "../logging/flush_job";
|
|
2
|
+
import type { SpanRow } from "./span_queue_repository";
|
|
3
|
+
export declare const SPAN_FLUSH_BATCH_SIZE = 100;
|
|
4
|
+
export declare const SPAN_MAX_BATCHES_PER_TICK = 5;
|
|
5
|
+
export interface SpanFlushJobState {
|
|
6
|
+
consecutiveFailures: number;
|
|
7
|
+
skipTicksRemaining: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function createSpanFlushJobState(): SpanFlushJobState;
|
|
10
|
+
export interface SpanFlushClient {
|
|
11
|
+
deliver(spans: SpanRow[]): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export interface SpanFlushQueue {
|
|
14
|
+
pruneExpired(): Promise<void>;
|
|
15
|
+
findPending(limit: number): Promise<SpanRow[]>;
|
|
16
|
+
removeMany(ids: number[]): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* One tick of the span flush cron.
|
|
20
|
+
*
|
|
21
|
+
* Mirrors runLogFlushJob (logs) — same at-least-once guarantee:
|
|
22
|
+
* deliver() always precedes removeMany(). A span delivered twice to the
|
|
23
|
+
* collector is preferable to a span never delivered.
|
|
24
|
+
*/
|
|
25
|
+
export declare function runSpanFlushJob(client: SpanFlushClient, queue: SpanFlushQueue, state: SpanFlushJobState): Promise<FlushResult>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const SPAN_FLUSH_BATCH_SIZE = 100;
|
|
2
|
+
export const SPAN_MAX_BATCHES_PER_TICK = 5;
|
|
3
|
+
const MAX_SKIP_TICKS = 32;
|
|
4
|
+
export function createSpanFlushJobState() {
|
|
5
|
+
return { consecutiveFailures: 0, skipTicksRemaining: 0 };
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* One tick of the span flush cron.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors runLogFlushJob (logs) — same at-least-once guarantee:
|
|
11
|
+
* deliver() always precedes removeMany(). A span delivered twice to the
|
|
12
|
+
* collector is preferable to a span never delivered.
|
|
13
|
+
*/
|
|
14
|
+
export async function runSpanFlushJob(client, queue, state) {
|
|
15
|
+
if (state.skipTicksRemaining > 0) {
|
|
16
|
+
state.skipTicksRemaining -= 1;
|
|
17
|
+
return { delivered: 0, failed: false };
|
|
18
|
+
}
|
|
19
|
+
await queue.pruneExpired();
|
|
20
|
+
let batchesDelivered = 0;
|
|
21
|
+
while (batchesDelivered < SPAN_MAX_BATCHES_PER_TICK) {
|
|
22
|
+
const spans = await queue.findPending(SPAN_FLUSH_BATCH_SIZE);
|
|
23
|
+
if (spans.length === 0)
|
|
24
|
+
break;
|
|
25
|
+
try {
|
|
26
|
+
await client.deliver(spans);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
state.consecutiveFailures += 1;
|
|
30
|
+
state.skipTicksRemaining = Math.min(Math.pow(2, state.consecutiveFailures - 1), MAX_SKIP_TICKS);
|
|
31
|
+
return { delivered: batchesDelivered, failed: true };
|
|
32
|
+
}
|
|
33
|
+
await queue.removeMany(spans.map((s) => s.id));
|
|
34
|
+
batchesDelivered += 1;
|
|
35
|
+
if (spans.length < SPAN_FLUSH_BATCH_SIZE)
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
if (batchesDelivered > 0) {
|
|
39
|
+
state.consecutiveFailures = 0;
|
|
40
|
+
}
|
|
41
|
+
return { delivered: batchesDelivered, failed: false };
|
|
42
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { DBCursor } from "../../../infrastructure/database";
|
|
2
|
+
import type { BufferStats } from "../types";
|
|
3
|
+
import type { SpanSampler } from "./span_sampler";
|
|
4
|
+
export interface SpanRow {
|
|
5
|
+
id: number;
|
|
6
|
+
trace_id: string;
|
|
7
|
+
span_id: string;
|
|
8
|
+
parent_span_id: string | null;
|
|
9
|
+
name: string;
|
|
10
|
+
kind: number;
|
|
11
|
+
start_time_unixnano: string;
|
|
12
|
+
end_time_unixnano: string;
|
|
13
|
+
attributes: string;
|
|
14
|
+
status_code: number;
|
|
15
|
+
status_message: string;
|
|
16
|
+
resource_attrs: string;
|
|
17
|
+
/** JSON array of {name, timeUnixNano, attributes} — from span.recordException() etc. */
|
|
18
|
+
events: string;
|
|
19
|
+
/** JSON array of {traceId, spanId, attributes} — cross-service links. */
|
|
20
|
+
links: string;
|
|
21
|
+
created_at: string;
|
|
22
|
+
}
|
|
23
|
+
export type SpanInput = Omit<SpanRow, "id" | "created_at">;
|
|
24
|
+
/** Max rows kept in spans_queue before eviction kicks in. */
|
|
25
|
+
export declare const MAX_SPANS_QUEUE_SIZE = 5000;
|
|
26
|
+
/** Max approximate payload bytes kept in spans_queue (~6 MB). */
|
|
27
|
+
export declare const MAX_SPANS_QUEUE_BYTES = 6000000;
|
|
28
|
+
/** Run the (scanning) eviction check once every N enqueues — see class doc. */
|
|
29
|
+
export declare const EVICT_CHECK_EVERY = 32;
|
|
30
|
+
/** Fraction of either budget above which head sampling activates. */
|
|
31
|
+
export declare const PRESSURE_FRACTION = 0.8;
|
|
32
|
+
/**
|
|
33
|
+
* Offline-first span buffer with **trace-coherent** eviction.
|
|
34
|
+
*
|
|
35
|
+
* Spans are bounded by BOTH a row count and a payload-byte budget. When either
|
|
36
|
+
* is exceeded the OLDEST WHOLE TRACE is dropped — never individual spans —
|
|
37
|
+
* so survivors are always complete, usable traces. Dropping spans by row (FIFO)
|
|
38
|
+
* would evict root/parent spans first and leave orphaned children that render
|
|
39
|
+
* as broken traces downstream.
|
|
40
|
+
*
|
|
41
|
+
* The budget check scans the table (COUNT + SUM of lengths), so it runs
|
|
42
|
+
* **amortized** — once every `evictEvery` enqueues — to keep the hot path cheap
|
|
43
|
+
* under offline bursts. The bound is therefore soft: the queue may transiently
|
|
44
|
+
* exceed the cap by up to `evictEvery` rows before the next trim.
|
|
45
|
+
*
|
|
46
|
+
* When a {@link SpanSampler} is supplied, head sampling kicks in once the buffer
|
|
47
|
+
* passes {@link PRESSURE_FRACTION} of either budget: whole traces are dropped at
|
|
48
|
+
* ingest (coherently) so the buffer stops thrashing instead of inserting spans
|
|
49
|
+
* it would immediately evict. Pressure is read from the last amortized
|
|
50
|
+
* measurement — no extra scan on the hot path.
|
|
51
|
+
*
|
|
52
|
+
* Telemetry is best-effort: under sustained offline pressure data IS dropped,
|
|
53
|
+
* but coherently and visibly (see {@link stats}).
|
|
54
|
+
*/
|
|
55
|
+
export declare class SpanQueueRepository {
|
|
56
|
+
private readonly db;
|
|
57
|
+
private readonly maxRows;
|
|
58
|
+
private readonly maxBytes;
|
|
59
|
+
private readonly evictEvery;
|
|
60
|
+
private readonly sampler;
|
|
61
|
+
private ops;
|
|
62
|
+
private dropped;
|
|
63
|
+
private sampledOut;
|
|
64
|
+
private lastRows;
|
|
65
|
+
private lastBytes;
|
|
66
|
+
private measuredOnce;
|
|
67
|
+
constructor(db: typeof DBCursor, maxRows?: number, maxBytes?: number, evictEvery?: number, sampler?: SpanSampler | null);
|
|
68
|
+
enqueue(span: SpanInput): Promise<void>;
|
|
69
|
+
/** Cheap pressure check from the last amortized measurement (no scan). */
|
|
70
|
+
private underPressure;
|
|
71
|
+
/**
|
|
72
|
+
* Drops the oldest whole traces until both row and byte budgets are met.
|
|
73
|
+
* Terminates: each pass either returns (under budget / empty) or removes ≥1
|
|
74
|
+
* row, and the row count strictly decreases.
|
|
75
|
+
*/
|
|
76
|
+
private evict;
|
|
77
|
+
private measure;
|
|
78
|
+
stats(): Promise<BufferStats>;
|
|
79
|
+
findPending(limit?: number): Promise<SpanRow[]>;
|
|
80
|
+
removeMany(ids: number[]): Promise<void>;
|
|
81
|
+
pruneExpired(days?: number): Promise<void>;
|
|
82
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/** Max rows kept in spans_queue before eviction kicks in. */
|
|
2
|
+
export const MAX_SPANS_QUEUE_SIZE = 5_000;
|
|
3
|
+
/** Max approximate payload bytes kept in spans_queue (~6 MB). */
|
|
4
|
+
export const MAX_SPANS_QUEUE_BYTES = 6_000_000;
|
|
5
|
+
/** Run the (scanning) eviction check once every N enqueues — see class doc. */
|
|
6
|
+
export const EVICT_CHECK_EVERY = 32;
|
|
7
|
+
/** Fraction of either budget above which head sampling activates. */
|
|
8
|
+
export const PRESSURE_FRACTION = 0.8;
|
|
9
|
+
/**
|
|
10
|
+
* Offline-first span buffer with **trace-coherent** eviction.
|
|
11
|
+
*
|
|
12
|
+
* Spans are bounded by BOTH a row count and a payload-byte budget. When either
|
|
13
|
+
* is exceeded the OLDEST WHOLE TRACE is dropped — never individual spans —
|
|
14
|
+
* so survivors are always complete, usable traces. Dropping spans by row (FIFO)
|
|
15
|
+
* would evict root/parent spans first and leave orphaned children that render
|
|
16
|
+
* as broken traces downstream.
|
|
17
|
+
*
|
|
18
|
+
* The budget check scans the table (COUNT + SUM of lengths), so it runs
|
|
19
|
+
* **amortized** — once every `evictEvery` enqueues — to keep the hot path cheap
|
|
20
|
+
* under offline bursts. The bound is therefore soft: the queue may transiently
|
|
21
|
+
* exceed the cap by up to `evictEvery` rows before the next trim.
|
|
22
|
+
*
|
|
23
|
+
* When a {@link SpanSampler} is supplied, head sampling kicks in once the buffer
|
|
24
|
+
* passes {@link PRESSURE_FRACTION} of either budget: whole traces are dropped at
|
|
25
|
+
* ingest (coherently) so the buffer stops thrashing instead of inserting spans
|
|
26
|
+
* it would immediately evict. Pressure is read from the last amortized
|
|
27
|
+
* measurement — no extra scan on the hot path.
|
|
28
|
+
*
|
|
29
|
+
* Telemetry is best-effort: under sustained offline pressure data IS dropped,
|
|
30
|
+
* but coherently and visibly (see {@link stats}).
|
|
31
|
+
*/
|
|
32
|
+
export class SpanQueueRepository {
|
|
33
|
+
db;
|
|
34
|
+
maxRows;
|
|
35
|
+
maxBytes;
|
|
36
|
+
evictEvery;
|
|
37
|
+
sampler;
|
|
38
|
+
ops = 0;
|
|
39
|
+
dropped = 0;
|
|
40
|
+
sampledOut = 0;
|
|
41
|
+
// Last measured size — drives the cheap pressure check on the hot path.
|
|
42
|
+
lastRows = 0;
|
|
43
|
+
lastBytes = 0;
|
|
44
|
+
// Whether the buffer size has been measured at least once (cold-start guard).
|
|
45
|
+
measuredOnce = false;
|
|
46
|
+
constructor(db, maxRows = MAX_SPANS_QUEUE_SIZE, maxBytes = MAX_SPANS_QUEUE_BYTES, evictEvery = EVICT_CHECK_EVERY, sampler = null) {
|
|
47
|
+
this.db = db;
|
|
48
|
+
this.maxRows = maxRows;
|
|
49
|
+
this.maxBytes = maxBytes;
|
|
50
|
+
this.evictEvery = Math.max(1, evictEvery);
|
|
51
|
+
this.sampler = sampler;
|
|
52
|
+
}
|
|
53
|
+
async enqueue(span) {
|
|
54
|
+
// Head sampling — drop whole traces at ingest while under pressure, so we
|
|
55
|
+
// don't insert spans only to evict them moments later. Coherent per trace.
|
|
56
|
+
if (this.sampler) {
|
|
57
|
+
// Cold-start guard: measure once before the first sampling decision so a
|
|
58
|
+
// buffer that booted already-full (SQLite persists) is seen as pressured.
|
|
59
|
+
if (!this.measuredOnce)
|
|
60
|
+
await this.measure();
|
|
61
|
+
if (!this.sampler.shouldKeep(span.trace_id, this.underPressure())) {
|
|
62
|
+
this.sampledOut += 1;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
await this.db.mutateDatabase(`INSERT INTO spans_queue
|
|
67
|
+
(trace_id, span_id, parent_span_id, name, kind,
|
|
68
|
+
start_time_unixnano, end_time_unixnano,
|
|
69
|
+
attributes, status_code, status_message, resource_attrs,
|
|
70
|
+
events, links)
|
|
71
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, span.trace_id, span.span_id, span.parent_span_id, span.name, span.kind, span.start_time_unixnano, span.end_time_unixnano, span.attributes, span.status_code, span.status_message, span.resource_attrs, span.events, span.links);
|
|
72
|
+
if (++this.ops >= this.evictEvery) {
|
|
73
|
+
this.ops = 0;
|
|
74
|
+
await this.evict();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Cheap pressure check from the last amortized measurement (no scan). */
|
|
78
|
+
underPressure() {
|
|
79
|
+
return (this.lastRows >= this.maxRows * PRESSURE_FRACTION ||
|
|
80
|
+
this.lastBytes >= this.maxBytes * PRESSURE_FRACTION);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Drops the oldest whole traces until both row and byte budgets are met.
|
|
84
|
+
* Terminates: each pass either returns (under budget / empty) or removes ≥1
|
|
85
|
+
* row, and the row count strictly decreases.
|
|
86
|
+
*/
|
|
87
|
+
async evict() {
|
|
88
|
+
for (;;) {
|
|
89
|
+
const { rows, approxBytes } = await this.measure();
|
|
90
|
+
if (rows <= this.maxRows && approxBytes <= this.maxBytes)
|
|
91
|
+
return;
|
|
92
|
+
const res = await this.db.mutateDatabase(`DELETE FROM spans_queue WHERE trace_id = (
|
|
93
|
+
SELECT trace_id FROM spans_queue GROUP BY trace_id ORDER BY MIN(id) ASC LIMIT 1
|
|
94
|
+
)`);
|
|
95
|
+
const removed = res?.changes ?? 0;
|
|
96
|
+
if (removed === 0)
|
|
97
|
+
return; // queue empty — nothing left to evict
|
|
98
|
+
this.dropped += removed;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async measure() {
|
|
102
|
+
const row = await this.db.getFirstAsync(`SELECT COUNT(*) AS rows,
|
|
103
|
+
COALESCE(SUM(
|
|
104
|
+
length(trace_id) + length(span_id) + length(COALESCE(parent_span_id, ''))
|
|
105
|
+
+ length(name) + length(start_time_unixnano) + length(end_time_unixnano)
|
|
106
|
+
+ length(attributes) + length(status_message) + length(resource_attrs)
|
|
107
|
+
+ length(events) + length(links)
|
|
108
|
+
), 0) AS bytes
|
|
109
|
+
FROM spans_queue`);
|
|
110
|
+
const rows = row?.rows ?? 0;
|
|
111
|
+
const approxBytes = row?.bytes ?? 0;
|
|
112
|
+
// Cache for the hot-path pressure check.
|
|
113
|
+
this.lastRows = rows;
|
|
114
|
+
this.lastBytes = approxBytes;
|
|
115
|
+
this.measuredOnce = true;
|
|
116
|
+
return { rows, approxBytes };
|
|
117
|
+
}
|
|
118
|
+
async stats() {
|
|
119
|
+
const { rows, approxBytes } = await this.measure();
|
|
120
|
+
return { rows, approxBytes, dropped: this.dropped, sampled: this.sampledOut };
|
|
121
|
+
}
|
|
122
|
+
async findPending(limit = 100) {
|
|
123
|
+
return this.db.getAllAsync(`SELECT * FROM spans_queue ORDER BY id ASC LIMIT ?`, limit);
|
|
124
|
+
}
|
|
125
|
+
async removeMany(ids) {
|
|
126
|
+
if (ids.length === 0)
|
|
127
|
+
return;
|
|
128
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
129
|
+
await this.db.mutateDatabase(`DELETE FROM spans_queue WHERE id IN (${placeholders})`, ...ids);
|
|
130
|
+
}
|
|
131
|
+
async pruneExpired(days = 7) {
|
|
132
|
+
await this.db.mutateDatabase(`DELETE FROM spans_queue WHERE created_at < datetime('now', ?)`, `-${days} days`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Keep 1 of every N traces while under pressure (default). */
|
|
2
|
+
export declare const DEFAULT_KEEP_ONE_IN_N = 4;
|
|
3
|
+
/** Bounded size of the per-trace decision cache. */
|
|
4
|
+
export declare const DEFAULT_DECISION_CACHE_SIZE = 2000;
|
|
5
|
+
/**
|
|
6
|
+
* Head sampler for spans, applied at ingest under sustained buffer pressure.
|
|
7
|
+
*
|
|
8
|
+
* The decision is **per trace and coherent**: every span sharing a `traceId`
|
|
9
|
+
* gets the same keep/drop verdict, even if buffer pressure changes between the
|
|
10
|
+
* arrival of one span and the next. This is what prevents partial/orphaned
|
|
11
|
+
* traces — the failure mode that makes a trace backend show broken graphs.
|
|
12
|
+
*
|
|
13
|
+
* Coherence is achieved two ways:
|
|
14
|
+
* - the keep verdict for a new trace is a deterministic hash of its id
|
|
15
|
+
* (no RNG), so the same id always hashes the same bucket; and
|
|
16
|
+
* - the first verdict for a trace is cached, so a mid-trace pressure flip can
|
|
17
|
+
* never change an already-made decision.
|
|
18
|
+
*
|
|
19
|
+
* When NOT under pressure every trace is kept. The cache is bounded (oldest
|
|
20
|
+
* decision evicted first). The cache is **LRU**: reading a trace's verdict
|
|
21
|
+
* refreshes it, so a trace still actively receiving spans keeps its decision
|
|
22
|
+
* resident and cannot be split by eviction. Only a trace idle long enough to
|
|
23
|
+
* fall out of the cache *and* hitting a pressure change can be re-decided —
|
|
24
|
+
* acceptable for best-effort telemetry.
|
|
25
|
+
*/
|
|
26
|
+
export declare class SpanSampler {
|
|
27
|
+
private readonly keepOneInN;
|
|
28
|
+
private readonly cacheSize;
|
|
29
|
+
private readonly decisions;
|
|
30
|
+
constructor(keepOneInN?: number, cacheSize?: number);
|
|
31
|
+
/**
|
|
32
|
+
* Returns whether the span belonging to `traceId` should be buffered.
|
|
33
|
+
* @param underPressure - true when the buffer is near its budget; only then
|
|
34
|
+
* does sampling drop anything.
|
|
35
|
+
*/
|
|
36
|
+
shouldKeep(traceId: string, underPressure: boolean): boolean;
|
|
37
|
+
/** Current number of cached trace decisions (for observability / tests). */
|
|
38
|
+
size(): number;
|
|
39
|
+
private remember;
|
|
40
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Keep 1 of every N traces while under pressure (default). */
|
|
2
|
+
export const DEFAULT_KEEP_ONE_IN_N = 4;
|
|
3
|
+
/** Bounded size of the per-trace decision cache. */
|
|
4
|
+
export const DEFAULT_DECISION_CACHE_SIZE = 2_000;
|
|
5
|
+
/**
|
|
6
|
+
* Head sampler for spans, applied at ingest under sustained buffer pressure.
|
|
7
|
+
*
|
|
8
|
+
* The decision is **per trace and coherent**: every span sharing a `traceId`
|
|
9
|
+
* gets the same keep/drop verdict, even if buffer pressure changes between the
|
|
10
|
+
* arrival of one span and the next. This is what prevents partial/orphaned
|
|
11
|
+
* traces — the failure mode that makes a trace backend show broken graphs.
|
|
12
|
+
*
|
|
13
|
+
* Coherence is achieved two ways:
|
|
14
|
+
* - the keep verdict for a new trace is a deterministic hash of its id
|
|
15
|
+
* (no RNG), so the same id always hashes the same bucket; and
|
|
16
|
+
* - the first verdict for a trace is cached, so a mid-trace pressure flip can
|
|
17
|
+
* never change an already-made decision.
|
|
18
|
+
*
|
|
19
|
+
* When NOT under pressure every trace is kept. The cache is bounded (oldest
|
|
20
|
+
* decision evicted first). The cache is **LRU**: reading a trace's verdict
|
|
21
|
+
* refreshes it, so a trace still actively receiving spans keeps its decision
|
|
22
|
+
* resident and cannot be split by eviction. Only a trace idle long enough to
|
|
23
|
+
* fall out of the cache *and* hitting a pressure change can be re-decided —
|
|
24
|
+
* acceptable for best-effort telemetry.
|
|
25
|
+
*/
|
|
26
|
+
export class SpanSampler {
|
|
27
|
+
keepOneInN;
|
|
28
|
+
cacheSize;
|
|
29
|
+
// insertion-ordered Map used as an LRU: most-recently-used key sits last.
|
|
30
|
+
decisions = new Map();
|
|
31
|
+
constructor(keepOneInN = DEFAULT_KEEP_ONE_IN_N, cacheSize = DEFAULT_DECISION_CACHE_SIZE) {
|
|
32
|
+
this.keepOneInN = Math.max(1, keepOneInN);
|
|
33
|
+
this.cacheSize = Math.max(1, cacheSize);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Returns whether the span belonging to `traceId` should be buffered.
|
|
37
|
+
* @param underPressure - true when the buffer is near its budget; only then
|
|
38
|
+
* does sampling drop anything.
|
|
39
|
+
*/
|
|
40
|
+
shouldKeep(traceId, underPressure) {
|
|
41
|
+
const cached = this.decisions.get(traceId);
|
|
42
|
+
if (cached !== undefined) {
|
|
43
|
+
// Touch — move to MRU position so active traces are never evicted.
|
|
44
|
+
this.decisions.delete(traceId);
|
|
45
|
+
this.decisions.set(traceId, cached);
|
|
46
|
+
return cached;
|
|
47
|
+
}
|
|
48
|
+
// keepOneInN === 1 means "keep all" even under pressure.
|
|
49
|
+
const keep = !underPressure || this.keepOneInN === 1 || hash(traceId) % this.keepOneInN === 0;
|
|
50
|
+
this.remember(traceId, keep);
|
|
51
|
+
return keep;
|
|
52
|
+
}
|
|
53
|
+
/** Current number of cached trace decisions (for observability / tests). */
|
|
54
|
+
size() {
|
|
55
|
+
return this.decisions.size;
|
|
56
|
+
}
|
|
57
|
+
remember(traceId, keep) {
|
|
58
|
+
if (this.decisions.size >= this.cacheSize) {
|
|
59
|
+
// Evict the least-recently-used decision (first key in insertion order).
|
|
60
|
+
const lru = this.decisions.keys().next().value;
|
|
61
|
+
if (lru !== undefined)
|
|
62
|
+
this.decisions.delete(lru);
|
|
63
|
+
}
|
|
64
|
+
this.decisions.set(traceId, keep);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** FNV-1a 32-bit — deterministic, fast, no allocations. */
|
|
68
|
+
function hash(s) {
|
|
69
|
+
let h = 0x811c9dc5;
|
|
70
|
+
for (let i = 0; i < s.length; i++) {
|
|
71
|
+
h ^= s.charCodeAt(i);
|
|
72
|
+
h = Math.imul(h, 0x01000193);
|
|
73
|
+
}
|
|
74
|
+
return h >>> 0; // unsigned
|
|
75
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { SpanInput } from "./span_queue_repository";
|
|
2
|
+
/**
|
|
3
|
+
* OTel HrTime: [seconds: number, nanoseconds: number].
|
|
4
|
+
* BigInt required — seconds alone can be ~1.7e9, multiplied by 1e9 = 1.7e18,
|
|
5
|
+
* which exceeds Number.MAX_SAFE_INTEGER (~9e15).
|
|
6
|
+
*/
|
|
7
|
+
export declare function hrTimeToNanoString(hrTime: [number, number]): string;
|
|
8
|
+
/**
|
|
9
|
+
* Minimal shape of OTel ReadableSpan needed for serialization.
|
|
10
|
+
* Using a local interface instead of importing from sdk-trace-base so this
|
|
11
|
+
* module stays importable in the Node test runner (OTel packages are CJS).
|
|
12
|
+
*/
|
|
13
|
+
export interface SerializableSpanEvent {
|
|
14
|
+
name: string;
|
|
15
|
+
time: [number, number];
|
|
16
|
+
attributes?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
export interface SerializableSpanLink {
|
|
19
|
+
context: {
|
|
20
|
+
traceId: string;
|
|
21
|
+
spanId: string;
|
|
22
|
+
};
|
|
23
|
+
attributes?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
export interface SerializableSpan {
|
|
26
|
+
name: string;
|
|
27
|
+
kind: number;
|
|
28
|
+
spanContext(): {
|
|
29
|
+
traceId: string;
|
|
30
|
+
spanId: string;
|
|
31
|
+
};
|
|
32
|
+
parentSpanContext?: {
|
|
33
|
+
spanId: string;
|
|
34
|
+
} | undefined;
|
|
35
|
+
startTime: [number, number];
|
|
36
|
+
endTime: [number, number];
|
|
37
|
+
attributes: Record<string, unknown>;
|
|
38
|
+
status: {
|
|
39
|
+
code: number;
|
|
40
|
+
message?: string;
|
|
41
|
+
};
|
|
42
|
+
resource?: {
|
|
43
|
+
attributes: Record<string, unknown>;
|
|
44
|
+
};
|
|
45
|
+
events?: SerializableSpanEvent[];
|
|
46
|
+
links?: SerializableSpanLink[];
|
|
47
|
+
}
|
|
48
|
+
export declare function serializeSpan(span: SerializableSpan): SpanInput;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTel HrTime: [seconds: number, nanoseconds: number].
|
|
3
|
+
* BigInt required — seconds alone can be ~1.7e9, multiplied by 1e9 = 1.7e18,
|
|
4
|
+
* which exceeds Number.MAX_SAFE_INTEGER (~9e15).
|
|
5
|
+
*/
|
|
6
|
+
export function hrTimeToNanoString(hrTime) {
|
|
7
|
+
return String(BigInt(hrTime[0]) * 1000000000n + BigInt(hrTime[1]));
|
|
8
|
+
}
|
|
9
|
+
export function serializeSpan(span) {
|
|
10
|
+
const ctx = span.spanContext();
|
|
11
|
+
const events = (span.events ?? []).map((e) => ({
|
|
12
|
+
name: e.name,
|
|
13
|
+
timeUnixNano: hrTimeToNanoString(e.time),
|
|
14
|
+
attributes: e.attributes ?? {},
|
|
15
|
+
}));
|
|
16
|
+
const links = (span.links ?? []).map((l) => ({
|
|
17
|
+
traceId: l.context.traceId,
|
|
18
|
+
spanId: l.context.spanId,
|
|
19
|
+
attributes: l.attributes ?? {},
|
|
20
|
+
}));
|
|
21
|
+
return {
|
|
22
|
+
trace_id: ctx.traceId,
|
|
23
|
+
span_id: ctx.spanId,
|
|
24
|
+
parent_span_id: span.parentSpanContext?.spanId ?? null,
|
|
25
|
+
name: span.name,
|
|
26
|
+
kind: span.kind,
|
|
27
|
+
start_time_unixnano: hrTimeToNanoString(span.startTime),
|
|
28
|
+
end_time_unixnano: hrTimeToNanoString(span.endTime),
|
|
29
|
+
attributes: JSON.stringify(span.attributes ?? {}),
|
|
30
|
+
status_code: span.status.code,
|
|
31
|
+
status_message: span.status.message ?? "",
|
|
32
|
+
resource_attrs: JSON.stringify(span.resource?.attributes ?? {}),
|
|
33
|
+
events: JSON.stringify(events),
|
|
34
|
+
links: JSON.stringify(links),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ExportResult } from "@opentelemetry/core";
|
|
2
|
+
import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base";
|
|
3
|
+
import type { SpanQueueRepository } from "./span_queue_repository";
|
|
4
|
+
/**
|
|
5
|
+
* OTel SpanExporter that persists spans to SQLite instead of sending them
|
|
6
|
+
* directly over the network. The flush cron drains the queue when online.
|
|
7
|
+
*
|
|
8
|
+
* Pass `enabled: false` to register the provider without storing anything —
|
|
9
|
+
* useful when tracing is initialized but no OTLP destination is configured yet.
|
|
10
|
+
*
|
|
11
|
+
* Fire-and-forget: export() never blocks the instrumented code path.
|
|
12
|
+
* Failures are swallowed — a span dropped from the queue is preferable to
|
|
13
|
+
* crashing the application.
|
|
14
|
+
*/
|
|
15
|
+
export declare class SQLiteSpanExporter implements SpanExporter {
|
|
16
|
+
private readonly queue;
|
|
17
|
+
private readonly enabled;
|
|
18
|
+
private readonly onEnqueued?;
|
|
19
|
+
/**
|
|
20
|
+
* @param onEnqueued - called after spans are persisted, so the pipeline can
|
|
21
|
+
* opportunistically attempt delivery (store-first, then try to send).
|
|
22
|
+
*/
|
|
23
|
+
constructor(queue: SpanQueueRepository, enabled?: boolean, onEnqueued?: () => void);
|
|
24
|
+
export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void;
|
|
25
|
+
shutdown(): Promise<void>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ExportResultCode } from "@opentelemetry/core";
|
|
2
|
+
import { serializeSpan } from "./span_serializer";
|
|
3
|
+
/**
|
|
4
|
+
* OTel SpanExporter that persists spans to SQLite instead of sending them
|
|
5
|
+
* directly over the network. The flush cron drains the queue when online.
|
|
6
|
+
*
|
|
7
|
+
* Pass `enabled: false` to register the provider without storing anything —
|
|
8
|
+
* useful when tracing is initialized but no OTLP destination is configured yet.
|
|
9
|
+
*
|
|
10
|
+
* Fire-and-forget: export() never blocks the instrumented code path.
|
|
11
|
+
* Failures are swallowed — a span dropped from the queue is preferable to
|
|
12
|
+
* crashing the application.
|
|
13
|
+
*/
|
|
14
|
+
export class SQLiteSpanExporter {
|
|
15
|
+
queue;
|
|
16
|
+
enabled;
|
|
17
|
+
onEnqueued;
|
|
18
|
+
/**
|
|
19
|
+
* @param onEnqueued - called after spans are persisted, so the pipeline can
|
|
20
|
+
* opportunistically attempt delivery (store-first, then try to send).
|
|
21
|
+
*/
|
|
22
|
+
constructor(queue, enabled = true, onEnqueued) {
|
|
23
|
+
this.queue = queue;
|
|
24
|
+
this.enabled = enabled;
|
|
25
|
+
this.onEnqueued = onEnqueued;
|
|
26
|
+
}
|
|
27
|
+
export(spans, resultCallback) {
|
|
28
|
+
if (!this.enabled) {
|
|
29
|
+
resultCallback({ code: ExportResultCode.SUCCESS });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const persist = async () => {
|
|
33
|
+
for (const span of spans) {
|
|
34
|
+
await this.queue.enqueue(serializeSpan(span));
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
persist()
|
|
38
|
+
.then(() => {
|
|
39
|
+
this.onEnqueued?.();
|
|
40
|
+
resultCallback({ code: ExportResultCode.SUCCESS });
|
|
41
|
+
})
|
|
42
|
+
.catch(() => resultCallback({ code: ExportResultCode.FAILED }));
|
|
43
|
+
}
|
|
44
|
+
shutdown() {
|
|
45
|
+
return Promise.resolve();
|
|
46
|
+
}
|
|
47
|
+
}
|