@sincpro/mobile 0.3.1 → 0.4.1
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/db/migrations.d.ts +3 -1
- package/dist/entrypoints/db/migrations.js +65 -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/framework/base_module.d.ts +1 -0
- package/dist/framework/base_module.js +6 -1
- package/dist/framework/createApp.d.ts +6 -0
- package/dist/framework/createApp.js +5 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -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 +65 -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/ui/components/molecules/TimeZoneSelectorModal.js +7 -1
- package/package.json +17 -11
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SpanQueueRepository } from "./span_queue_repository";
|
|
2
|
+
export interface TracingResourceConfig {
|
|
3
|
+
serviceName?: string;
|
|
4
|
+
serviceVersion?: string;
|
|
5
|
+
deploymentEnvironment?: string;
|
|
6
|
+
/** Extra OTel resource attributes (device.*, os.*, tenant, …). */
|
|
7
|
+
extra?: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* @param queue - repository used by the exporter to buffer spans
|
|
11
|
+
* @param persist - when false the TracerProvider is registered (decorators work)
|
|
12
|
+
* but spans are not written to SQLite; pass true only when an
|
|
13
|
+
* OTLP destination is configured and flush is active
|
|
14
|
+
* @param onEnqueued - called after spans are buffered, so the pipeline can
|
|
15
|
+
* opportunistically attempt delivery (store-first)
|
|
16
|
+
* @param resourceConfig - OTel resource attributes; without `serviceName` spans appear
|
|
17
|
+
* as `unknown_service` in Grafana/Tempo
|
|
18
|
+
*/
|
|
19
|
+
export declare function initTracing(queue: SpanQueueRepository, persist?: boolean, onEnqueued?: () => void, resourceConfig?: TracingResourceConfig): void;
|
|
20
|
+
/** @internal — test use only */
|
|
21
|
+
export declare function _resetTracing(): void;
|
|
22
|
+
export declare function getTracer(name: string): import("@opentelemetry/api").Tracer;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { trace } from "@opentelemetry/api";
|
|
2
|
+
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
3
|
+
import { BasicTracerProvider, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
4
|
+
import { SQLiteSpanExporter } from "./sqlite_span_exporter";
|
|
5
|
+
let _provider = null;
|
|
6
|
+
/**
|
|
7
|
+
* @param queue - repository used by the exporter to buffer spans
|
|
8
|
+
* @param persist - when false the TracerProvider is registered (decorators work)
|
|
9
|
+
* but spans are not written to SQLite; pass true only when an
|
|
10
|
+
* OTLP destination is configured and flush is active
|
|
11
|
+
* @param onEnqueued - called after spans are buffered, so the pipeline can
|
|
12
|
+
* opportunistically attempt delivery (store-first)
|
|
13
|
+
* @param resourceConfig - OTel resource attributes; without `serviceName` spans appear
|
|
14
|
+
* as `unknown_service` in Grafana/Tempo
|
|
15
|
+
*/
|
|
16
|
+
export function initTracing(queue, persist = true, onEnqueued, resourceConfig) {
|
|
17
|
+
_provider?.shutdown().catch(() => { });
|
|
18
|
+
const rawAttrs = {};
|
|
19
|
+
if (resourceConfig?.serviceName)
|
|
20
|
+
rawAttrs["service.name"] = resourceConfig.serviceName;
|
|
21
|
+
if (resourceConfig?.serviceVersion)
|
|
22
|
+
rawAttrs["service.version"] = resourceConfig.serviceVersion;
|
|
23
|
+
if (resourceConfig?.deploymentEnvironment)
|
|
24
|
+
rawAttrs["deployment.environment"] = resourceConfig.deploymentEnvironment;
|
|
25
|
+
if (resourceConfig?.extra)
|
|
26
|
+
Object.assign(rawAttrs, resourceConfig.extra);
|
|
27
|
+
const resource = Object.keys(rawAttrs).length > 0 ? resourceFromAttributes(rawAttrs) : undefined;
|
|
28
|
+
const provider = new BasicTracerProvider({
|
|
29
|
+
resource,
|
|
30
|
+
spanProcessors: [
|
|
31
|
+
new SimpleSpanProcessor(new SQLiteSpanExporter(queue, persist, onEnqueued)),
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
trace.setGlobalTracerProvider(provider);
|
|
35
|
+
_provider = provider;
|
|
36
|
+
}
|
|
37
|
+
/** @internal — test use only */
|
|
38
|
+
export function _resetTracing() {
|
|
39
|
+
_provider?.shutdown().catch(() => { });
|
|
40
|
+
_provider = null;
|
|
41
|
+
trace.disable();
|
|
42
|
+
}
|
|
43
|
+
export function getTracer(name) {
|
|
44
|
+
return trace.getTracer(name);
|
|
45
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { InterceptorHooks } from "../../interceptor/intercept";
|
|
2
|
+
/**
|
|
3
|
+
* Returns InterceptorHooks that record an OTel span for every intercepted
|
|
4
|
+
* method call. Parent-child relationships are maintained through the framework
|
|
5
|
+
* StackContextManager: the OTel context (with the active span) is stored as a
|
|
6
|
+
* value inside our Context, pushed in `before` and popped in `after`/`onError`.
|
|
7
|
+
*
|
|
8
|
+
* @param tracerName - instrumentation scope name, e.g. "sincpro.mobile.checkout"
|
|
9
|
+
*/
|
|
10
|
+
export declare function tracingHooks(tracerName: string): InterceptorHooks;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ROOT_CONTEXT as OTEL_ROOT, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
2
|
+
import { _getContextManager } from "../../context_manager/index";
|
|
3
|
+
import { OTEL_CTX_KEY } from "../context_keys";
|
|
4
|
+
const _spanMap = new WeakMap();
|
|
5
|
+
// Tracks whether `before` successfully pushed a context entry for this call.
|
|
6
|
+
// Guards `after`/`onError` against popping a context they never pushed.
|
|
7
|
+
const _pushedMap = new WeakSet();
|
|
8
|
+
/**
|
|
9
|
+
* Returns InterceptorHooks that record an OTel span for every intercepted
|
|
10
|
+
* method call. Parent-child relationships are maintained through the framework
|
|
11
|
+
* StackContextManager: the OTel context (with the active span) is stored as a
|
|
12
|
+
* value inside our Context, pushed in `before` and popped in `after`/`onError`.
|
|
13
|
+
*
|
|
14
|
+
* @param tracerName - instrumentation scope name, e.g. "sincpro.mobile.checkout"
|
|
15
|
+
*/
|
|
16
|
+
export function tracingHooks(tracerName) {
|
|
17
|
+
return {
|
|
18
|
+
before(call) {
|
|
19
|
+
const manager = _getContextManager();
|
|
20
|
+
const frameworkCtx = manager.active();
|
|
21
|
+
// Read the OTel context carried inside our framework context.
|
|
22
|
+
// Falls back to OTel ROOT if tracing has not been initialized yet.
|
|
23
|
+
const otelCtx = frameworkCtx.get(OTEL_CTX_KEY) ?? OTEL_ROOT;
|
|
24
|
+
const spanName = call.className
|
|
25
|
+
? `${call.className}.${call.methodName}`
|
|
26
|
+
: call.methodName;
|
|
27
|
+
const span = trace.getTracer(tracerName).startSpan(spanName, undefined, otelCtx);
|
|
28
|
+
_spanMap.set(call, span);
|
|
29
|
+
// Store the updated OTel context (now containing this span) back into
|
|
30
|
+
// the framework context and push it onto the stack so nested calls see
|
|
31
|
+
// this span as their parent.
|
|
32
|
+
const newOtelCtx = trace.setSpan(otelCtx, span);
|
|
33
|
+
manager.push(frameworkCtx.set(OTEL_CTX_KEY, newOtelCtx));
|
|
34
|
+
_pushedMap.add(call);
|
|
35
|
+
},
|
|
36
|
+
after(call) {
|
|
37
|
+
const span = _spanMap.get(call);
|
|
38
|
+
if (!span)
|
|
39
|
+
return;
|
|
40
|
+
_spanMap.delete(call);
|
|
41
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
42
|
+
span.end();
|
|
43
|
+
if (_pushedMap.delete(call))
|
|
44
|
+
_getContextManager().pop();
|
|
45
|
+
},
|
|
46
|
+
onError(call, error) {
|
|
47
|
+
const span = _spanMap.get(call);
|
|
48
|
+
if (!span)
|
|
49
|
+
return;
|
|
50
|
+
_spanMap.delete(call);
|
|
51
|
+
if (error instanceof Error)
|
|
52
|
+
span.recordException(error);
|
|
53
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message });
|
|
54
|
+
span.end();
|
|
55
|
+
if (_pushedMap.delete(call))
|
|
56
|
+
_getContextManager().pop();
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|