@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.
Files changed (100) hide show
  1. package/README.md +234 -2
  2. package/dist/entrypoints/cron/EBuiltinCron.d.ts +4 -0
  3. package/dist/entrypoints/cron/EBuiltinCron.js +5 -0
  4. package/dist/entrypoints/cron/checkNetworkStatus.cron.js +2 -1
  5. package/dist/entrypoints/db/migrations.d.ts +3 -1
  6. package/dist/entrypoints/db/migrations.js +47 -0
  7. package/dist/entrypoints/queue/QueueProcessor.js +1 -25
  8. package/dist/entrypoints/queue/subscriber_filter.d.ts +9 -0
  9. package/dist/entrypoints/queue/subscriber_filter.js +25 -0
  10. package/dist/entrypoints/ui/common_provider.js +2 -2
  11. package/dist/framework/base_module.d.ts +1 -0
  12. package/dist/framework/base_module.js +6 -1
  13. package/dist/framework/createApp.d.ts +14 -0
  14. package/dist/framework/createApp.js +6 -0
  15. package/dist/framework/kernel.d.ts +2 -1
  16. package/dist/framework/kernel.js +8 -0
  17. package/dist/index.d.ts +7 -0
  18. package/dist/index.js +4 -0
  19. package/dist/infrastructure/context_manager/context.d.ts +16 -0
  20. package/dist/infrastructure/context_manager/context.js +37 -0
  21. package/dist/infrastructure/context_manager/context_api.d.ts +28 -0
  22. package/dist/infrastructure/context_manager/context_api.js +55 -0
  23. package/dist/infrastructure/context_manager/context_key.d.ts +15 -0
  24. package/dist/infrastructure/context_manager/context_key.js +3 -0
  25. package/dist/infrastructure/context_manager/context_manager_facade.d.ts +107 -0
  26. package/dist/infrastructure/context_manager/context_manager_facade.js +113 -0
  27. package/dist/infrastructure/context_manager/decorators/propagate_context.d.ts +24 -0
  28. package/dist/infrastructure/context_manager/decorators/propagate_context.js +52 -0
  29. package/dist/infrastructure/context_manager/decorators/with_context.d.ts +34 -0
  30. package/dist/infrastructure/context_manager/decorators/with_context.js +44 -0
  31. package/dist/infrastructure/context_manager/index.d.ts +11 -0
  32. package/dist/infrastructure/context_manager/index.js +12 -0
  33. package/dist/infrastructure/context_manager/managed_context.d.ts +87 -0
  34. package/dist/infrastructure/context_manager/managed_context.js +144 -0
  35. package/dist/infrastructure/context_manager/stack_context_manager.d.ts +39 -0
  36. package/dist/infrastructure/context_manager/stack_context_manager.js +56 -0
  37. package/dist/infrastructure/database/connector.js +10 -1
  38. package/dist/infrastructure/interceptor/index.d.ts +4 -0
  39. package/dist/infrastructure/interceptor/index.js +2 -0
  40. package/dist/infrastructure/interceptor/intercept.d.ts +57 -0
  41. package/dist/infrastructure/interceptor/intercept.js +112 -0
  42. package/dist/infrastructure/interceptor/logging_hooks.d.ts +22 -0
  43. package/dist/infrastructure/interceptor/logging_hooks.js +27 -0
  44. package/dist/infrastructure/logger.d.ts +11 -16
  45. package/dist/infrastructure/logger.js +61 -18
  46. package/dist/infrastructure/telemetry/buffer_registry.d.ts +15 -0
  47. package/dist/infrastructure/telemetry/buffer_registry.js +26 -0
  48. package/dist/infrastructure/telemetry/connectivity_state.d.ts +20 -0
  49. package/dist/infrastructure/telemetry/connectivity_state.js +28 -0
  50. package/dist/infrastructure/telemetry/context_keys.d.ts +7 -0
  51. package/dist/infrastructure/telemetry/context_keys.js +7 -0
  52. package/dist/infrastructure/telemetry/fetch_with_timeout.d.ts +8 -0
  53. package/dist/infrastructure/telemetry/fetch_with_timeout.js +17 -0
  54. package/dist/infrastructure/telemetry/flush_telemetry.d.ts +40 -0
  55. package/dist/infrastructure/telemetry/flush_telemetry.js +72 -0
  56. package/dist/infrastructure/telemetry/index.d.ts +17 -0
  57. package/dist/infrastructure/telemetry/index.js +17 -0
  58. package/dist/infrastructure/telemetry/init_telemetry.d.ts +21 -0
  59. package/dist/infrastructure/telemetry/init_telemetry.js +85 -0
  60. package/dist/infrastructure/telemetry/logging/flush_job.d.ts +44 -0
  61. package/dist/infrastructure/telemetry/logging/flush_job.js +69 -0
  62. package/dist/infrastructure/telemetry/logging/log_queue_repository.d.ts +76 -0
  63. package/dist/infrastructure/telemetry/logging/log_queue_repository.js +147 -0
  64. package/dist/infrastructure/telemetry/logging/loki_client.d.ts +32 -0
  65. package/dist/infrastructure/telemetry/logging/loki_client.js +118 -0
  66. package/dist/infrastructure/telemetry/logging/loki_registry.d.ts +12 -0
  67. package/dist/infrastructure/telemetry/logging/loki_registry.js +17 -0
  68. package/dist/infrastructure/telemetry/telemetry_flush_worker.d.ts +35 -0
  69. package/dist/infrastructure/telemetry/telemetry_flush_worker.js +66 -0
  70. package/dist/infrastructure/telemetry/telemetry_signal.d.ts +18 -0
  71. package/dist/infrastructure/telemetry/telemetry_signal.js +33 -0
  72. package/dist/infrastructure/telemetry/tracing/active_span.d.ts +22 -0
  73. package/dist/infrastructure/telemetry/tracing/active_span.js +40 -0
  74. package/dist/infrastructure/telemetry/tracing/otlp_client.d.ts +13 -0
  75. package/dist/infrastructure/telemetry/tracing/otlp_client.js +34 -0
  76. package/dist/infrastructure/telemetry/tracing/otlp_serializer.d.ts +67 -0
  77. package/dist/infrastructure/telemetry/tracing/otlp_serializer.js +101 -0
  78. package/dist/infrastructure/telemetry/tracing/span_flush_job.d.ts +25 -0
  79. package/dist/infrastructure/telemetry/tracing/span_flush_job.js +42 -0
  80. package/dist/infrastructure/telemetry/tracing/span_queue_repository.d.ts +82 -0
  81. package/dist/infrastructure/telemetry/tracing/span_queue_repository.js +134 -0
  82. package/dist/infrastructure/telemetry/tracing/span_sampler.d.ts +40 -0
  83. package/dist/infrastructure/telemetry/tracing/span_sampler.js +75 -0
  84. package/dist/infrastructure/telemetry/tracing/span_serializer.d.ts +48 -0
  85. package/dist/infrastructure/telemetry/tracing/span_serializer.js +36 -0
  86. package/dist/infrastructure/telemetry/tracing/sqlite_span_exporter.d.ts +26 -0
  87. package/dist/infrastructure/telemetry/tracing/sqlite_span_exporter.js +47 -0
  88. package/dist/infrastructure/telemetry/tracing/tracer.d.ts +22 -0
  89. package/dist/infrastructure/telemetry/tracing/tracer.js +45 -0
  90. package/dist/infrastructure/telemetry/tracing/tracing_hooks.d.ts +10 -0
  91. package/dist/infrastructure/telemetry/tracing/tracing_hooks.js +59 -0
  92. package/dist/infrastructure/telemetry/tracing_facade.d.ts +50 -0
  93. package/dist/infrastructure/telemetry/tracing_facade.js +139 -0
  94. package/dist/infrastructure/telemetry/types.d.ts +120 -0
  95. package/dist/infrastructure/telemetry/types.js +1 -0
  96. package/dist/infrastructure/workers/CronWorker.d.ts +10 -2
  97. package/dist/infrastructure/workers/CronWorker.js +18 -5
  98. package/dist/infrastructure/workers/index.d.ts +1 -1
  99. package/dist/ui/components/molecules/TimeZoneSelectorModal.js +7 -1
  100. package/package.json +17 -11
@@ -0,0 +1,147 @@
1
+ /** Max rows kept in telemetry_queue before eviction kicks in. */
2
+ export const MAX_LOG_QUEUE_SIZE = 5_000;
3
+ /** Max approximate payload bytes kept in telemetry_queue (~2 MB). */
4
+ export const MAX_LOG_QUEUE_BYTES = 2_000_000;
5
+ /** Run the (scanning) eviction check once every N enqueues — see class doc. */
6
+ export const EVICT_CHECK_EVERY = 32;
7
+ /**
8
+ * Drop priority by level — LOWER is evicted FIRST. So under pressure debug/info
9
+ * go before warn, and error survives longest. Unknown levels fall back to
10
+ * {@link DEFAULT_DROP_PRIORITY}. Keys are the lowercase level strings the logger
11
+ * persists.
12
+ */
13
+ export const LOG_LEVEL_DROP_PRIORITY = {
14
+ debug: 0,
15
+ info: 1,
16
+ warn: 2,
17
+ error: 3,
18
+ };
19
+ const DEFAULT_DROP_PRIORITY = 1; // treat unknown levels like "info"
20
+ /**
21
+ * SQL `CASE` mapping level → drop priority, built from {@link LOG_LEVEL_DROP_PRIORITY}
22
+ * so the policy has a single source of truth. Values are integers and keys are
23
+ * fixed lowercase ASCII from our own constant — no injection surface.
24
+ */
25
+ function severityCaseSql() {
26
+ const whens = Object.entries(LOG_LEVEL_DROP_PRIORITY)
27
+ .map(([level, rank]) => `WHEN '${level}' THEN ${rank}`)
28
+ .join(" ");
29
+ return `CASE lower(level) ${whens} ELSE ${DEFAULT_DROP_PRIORITY} END`;
30
+ }
31
+ /**
32
+ * Offline-first log buffer, bounded by BOTH a row count and a payload-byte
33
+ * budget. When either is exceeded, rows are evicted **lowest-severity-first**
34
+ * (debug → info → warn → error), oldest-first within a severity — so error/warn
35
+ * logs survive longest under sustained offline pressure.
36
+ *
37
+ * The budget check scans the table, so it runs **amortized** — once every
38
+ * `evictEvery` enqueues — to keep the hot path cheap. The bound is therefore
39
+ * soft: the queue may transiently exceed the cap by up to `evictEvery` rows.
40
+ *
41
+ * Telemetry is best-effort: under sustained offline pressure logs ARE dropped,
42
+ * but the loss is bounded, severity-aware, and observable (see {@link stats}).
43
+ * Business-critical data must NOT live here.
44
+ */
45
+ export class LogQueueRepository {
46
+ db;
47
+ maxRows;
48
+ maxBytes;
49
+ evictEvery;
50
+ ops = 0;
51
+ dropped = 0;
52
+ constructor(db, maxRows = MAX_LOG_QUEUE_SIZE, maxBytes = MAX_LOG_QUEUE_BYTES, evictEvery = EVICT_CHECK_EVERY) {
53
+ this.db = db;
54
+ this.maxRows = maxRows;
55
+ this.maxBytes = maxBytes;
56
+ this.evictEvery = Math.max(1, evictEvery);
57
+ }
58
+ async enqueue(level, message) {
59
+ await this.db.mutateDatabase(`INSERT INTO telemetry_queue (level, message) VALUES (?, ?)`, level, message);
60
+ if (++this.ops >= this.evictEvery) {
61
+ this.ops = 0;
62
+ await this.evict();
63
+ }
64
+ }
65
+ /**
66
+ * Brings the queue under both budgets. The two budgets use DIFFERENT victim
67
+ * policies on purpose:
68
+ *
69
+ * - Row pressure → **severity-aware**: drop lowest-severity, oldest-first.
70
+ * Each deletion reduces the count by exactly one, so this is exact.
71
+ * - Byte pressure → **largest-first**, reclaiming ACTUAL bytes. Severity is
72
+ * deliberately NOT used here: when the overage is caused by one oversized
73
+ * payload, protecting it (severity-first) forces deleting everything else
74
+ * to free its bytes, annihilating the queue to clear an overage that row
75
+ * itself caused. Largest-first targets the cause directly, so the maximum
76
+ * number of (small, information-dense) entries survive. Corollary: a huge
77
+ * high-severity payload is NOT protected from byte eviction — keep large
78
+ * blobs out of logs.
79
+ *
80
+ * Terminates: each pass removes ≥1 row (count strictly decreases) or returns.
81
+ */
82
+ async evict() {
83
+ for (;;) {
84
+ const { rows, approxBytes } = await this.measure();
85
+ if (rows <= this.maxRows && approxBytes <= this.maxBytes)
86
+ return;
87
+ const removed = rows > this.maxRows
88
+ ? await this.dropBySeverity(rows - this.maxRows)
89
+ : await this.dropLargestUntilBytesFreed(approxBytes - this.maxBytes);
90
+ if (removed === 0)
91
+ return;
92
+ this.dropped += removed;
93
+ }
94
+ }
95
+ /** Deletes `n` rows lowest-severity-first, oldest-first within a tier. */
96
+ async dropBySeverity(n) {
97
+ const res = await this.db.mutateDatabase(`DELETE FROM telemetry_queue WHERE id IN (
98
+ SELECT id FROM telemetry_queue ORDER BY ${severityCaseSql()} ASC, id ASC LIMIT ?
99
+ )`, n);
100
+ return res?.changes ?? 0;
101
+ }
102
+ /**
103
+ * Deletes the largest rows (oldest as tiebreak) until at least `overage` bytes
104
+ * are reclaimed, using each row's ACTUAL size. Returns rows removed.
105
+ */
106
+ async dropLargestUntilBytesFreed(overage) {
107
+ const candidates = await this.db.getAllAsync(`SELECT id, (length(level) + length(message)) AS len
108
+ FROM telemetry_queue ORDER BY (length(level) + length(message)) DESC, id ASC LIMIT 500`);
109
+ if (candidates.length === 0)
110
+ return 0;
111
+ const ids = [];
112
+ let freed = 0;
113
+ for (const c of candidates) {
114
+ ids.push(c.id);
115
+ freed += c.len;
116
+ if (freed >= overage)
117
+ break;
118
+ }
119
+ const placeholders = ids.map(() => "?").join(", ");
120
+ const res = await this.db.mutateDatabase(`DELETE FROM telemetry_queue WHERE id IN (${placeholders})`, ...ids);
121
+ return res?.changes ?? ids.length;
122
+ }
123
+ async measure() {
124
+ const row = await this.db.getFirstAsync(`SELECT COUNT(*) AS rows,
125
+ COALESCE(SUM(length(level) + length(message)), 0) AS bytes
126
+ FROM telemetry_queue`);
127
+ return { rows: row?.rows ?? 0, approxBytes: row?.bytes ?? 0 };
128
+ }
129
+ async stats() {
130
+ const { rows, approxBytes } = await this.measure();
131
+ // Logs are not head-sampled — `sampled` is always 0.
132
+ return { rows, approxBytes, dropped: this.dropped, sampled: 0 };
133
+ }
134
+ async findPending(limit = 100) {
135
+ return this.db.getAllAsync(`SELECT id, level, message, created_at FROM telemetry_queue ORDER BY id ASC LIMIT ?`, limit);
136
+ }
137
+ async removeMany(ids) {
138
+ if (ids.length === 0)
139
+ return;
140
+ const placeholders = ids.map(() => "?").join(", ");
141
+ await this.db.mutateDatabase(`DELETE FROM telemetry_queue WHERE id IN (${placeholders})`, ...ids);
142
+ }
143
+ /** Deletes entries older than `days` days. Called before each flush to bound queue growth. */
144
+ async pruneExpired(days = 7) {
145
+ await this.db.mutateDatabase(`DELETE FROM telemetry_queue WHERE created_at < datetime('now', ?)`, `-${days} days`);
146
+ }
147
+ }
@@ -0,0 +1,32 @@
1
+ import type { LokiConfig } from "../types";
2
+ import type { LogEntry, LogQueueRepository } from "./log_queue_repository";
3
+ /** @internal — exposed for compliance testing only */
4
+ export declare function _nowNs(): string;
5
+ /**
6
+ * HTTP client for Loki's push API (`POST /loki/api/v1/push`).
7
+ *
8
+ * `push()` is fire-and-forget: on failure it enqueues to the outbox (if provided) so the
9
+ * flush cron can retry. `deliver()` is the awaitable batch-send used by the flush cron.
10
+ */
11
+ export declare class LokiClient {
12
+ private readonly config;
13
+ private readonly queue;
14
+ private readonly timeoutMs;
15
+ constructor(config: LokiConfig, queue?: LogQueueRepository, timeoutMs?: number);
16
+ push(level: string, message: string): void;
17
+ /**
18
+ * Batch-delivers outbox entries to Loki. Groups entries by level into separate streams.
19
+ * Throws on network error or non-2xx response — caller must not remove entries from the
20
+ * outbox unless this resolves successfully.
21
+ */
22
+ deliver(entries: LogEntry[]): Promise<void>;
23
+ /**
24
+ * Builds the headers for every request: the `auth` shortcut, then any custom
25
+ * `headers` (which win on conflict — e.g. a gateway API key, or an explicit
26
+ * Authorization overriding `auth`), then a non-negotiable `Content-Type`.
27
+ * The body is always JSON, so a custom `content-type` (any casing) must not
28
+ * override it — Loki would reject the push.
29
+ */
30
+ private requestHeaders;
31
+ private authHeader;
32
+ }
@@ -0,0 +1,118 @@
1
+ import { DEFAULT_SEND_TIMEOUT_MS, fetchWithTimeout } from "../fetch_with_timeout";
2
+ // ---------------------------------------------------------------------------
3
+ // Monotonic nanosecond timestamp
4
+ // Loki rejects duplicate timestamps within the same stream (HTTP 400).
5
+ // If two push() calls happen in the same millisecond they'd produce identical
6
+ // ns values — the counter guarantees strict monotonicity.
7
+ // ---------------------------------------------------------------------------
8
+ let _lastMs = 0;
9
+ let _nsSeq = 0;
10
+ /** @internal — exposed for compliance testing only */
11
+ export function _nowNs() {
12
+ const ms = Date.now();
13
+ if (ms === _lastMs) {
14
+ _nsSeq += 1;
15
+ }
16
+ else {
17
+ _lastMs = ms;
18
+ _nsSeq = 0;
19
+ }
20
+ // BigInt required: ms * 1_000_000 ≈ 1.78e18 in 2026, which exceeds Number.MAX_SAFE_INTEGER
21
+ // (~9e15). Float64 granularity at that magnitude is ~512, so the _nsSeq offset would be
22
+ // invisible — producing duplicate timestamps that Loki rejects with HTTP 400.
23
+ return String(BigInt(ms) * 1000000n + BigInt(_nsSeq));
24
+ }
25
+ /**
26
+ * HTTP client for Loki's push API (`POST /loki/api/v1/push`).
27
+ *
28
+ * `push()` is fire-and-forget: on failure it enqueues to the outbox (if provided) so the
29
+ * flush cron can retry. `deliver()` is the awaitable batch-send used by the flush cron.
30
+ */
31
+ export class LokiClient {
32
+ config;
33
+ queue;
34
+ timeoutMs;
35
+ constructor(config, queue, timeoutMs = DEFAULT_SEND_TIMEOUT_MS) {
36
+ this.config = config;
37
+ this.queue = queue ?? null;
38
+ this.timeoutMs = timeoutMs;
39
+ }
40
+ push(level, message) {
41
+ const body = JSON.stringify({
42
+ streams: [
43
+ {
44
+ stream: { ...this.config.labels, level },
45
+ values: [[_nowNs(), message]],
46
+ },
47
+ ],
48
+ });
49
+ fetchWithTimeout(`${this.config.endpoint}/loki/api/v1/push`, {
50
+ method: "POST",
51
+ headers: this.requestHeaders(),
52
+ body,
53
+ }, this.timeoutMs).catch(() => {
54
+ const q = this.queue;
55
+ if (q) {
56
+ queueMicrotask(() => q.enqueue(level, message).catch(() => { }));
57
+ }
58
+ });
59
+ }
60
+ /**
61
+ * Batch-delivers outbox entries to Loki. Groups entries by level into separate streams.
62
+ * Throws on network error or non-2xx response — caller must not remove entries from the
63
+ * outbox unless this resolves successfully.
64
+ */
65
+ async deliver(entries) {
66
+ if (entries.length === 0)
67
+ return;
68
+ const byLevel = new Map();
69
+ for (const entry of entries) {
70
+ if (!byLevel.has(entry.level))
71
+ byLevel.set(entry.level, []);
72
+ byLevel.get(entry.level).push(entry);
73
+ }
74
+ const streams = Array.from(byLevel.entries()).map(([level, levelEntries]) => ({
75
+ stream: { ...this.config.labels, level },
76
+ values: levelEntries.map((entry, i) => {
77
+ const baseNs = BigInt(new Date(entry.created_at.replace(" ", "T") + "Z").getTime()) * 1000000n;
78
+ return [String(baseNs + BigInt(i)), entry.message];
79
+ }),
80
+ }));
81
+ const res = await fetchWithTimeout(`${this.config.endpoint}/loki/api/v1/push`, {
82
+ method: "POST",
83
+ headers: this.requestHeaders(),
84
+ body: JSON.stringify({ streams }),
85
+ }, this.timeoutMs);
86
+ if (!res.ok) {
87
+ throw new Error(`Loki batch delivery failed: HTTP ${res.status}`);
88
+ }
89
+ }
90
+ /**
91
+ * Builds the headers for every request: the `auth` shortcut, then any custom
92
+ * `headers` (which win on conflict — e.g. a gateway API key, or an explicit
93
+ * Authorization overriding `auth`), then a non-negotiable `Content-Type`.
94
+ * The body is always JSON, so a custom `content-type` (any casing) must not
95
+ * override it — Loki would reject the push.
96
+ */
97
+ requestHeaders() {
98
+ const custom = { ...this.config.headers };
99
+ for (const key of Object.keys(custom)) {
100
+ if (key.toLowerCase() === "content-type")
101
+ delete custom[key];
102
+ }
103
+ return {
104
+ ...this.authHeader(),
105
+ ...custom,
106
+ "Content-Type": "application/json",
107
+ };
108
+ }
109
+ authHeader() {
110
+ const auth = this.config.auth;
111
+ if (!auth)
112
+ return {};
113
+ if (auth.type === "basic") {
114
+ return { Authorization: `Basic ${btoa(`${auth.username}:${auth.password}`)}` };
115
+ }
116
+ return { Authorization: `Bearer ${auth.token}` };
117
+ }
118
+ }
@@ -0,0 +1,12 @@
1
+ import type { LokiConfig } from "../types";
2
+ import type { LogQueueRepository } from "./log_queue_repository";
3
+ import { LokiClient } from "./loki_client";
4
+ /**
5
+ * Low-level client initializer. Prefer `initTelemetry` from the telemetry index, which also
6
+ * wires the SQLite outbox and the flush cron. Use this directly only in tests.
7
+ */
8
+ export declare function initLokiClient(config: LokiConfig, queue?: LogQueueRepository, timeoutMs?: number): void;
9
+ /** Returns the active LokiClient, or null if telemetry was not initialized. */
10
+ export declare function getLokiClient(): LokiClient | null;
11
+ /** @internal — test use only */
12
+ export declare function _resetLokiClient(): void;
@@ -0,0 +1,17 @@
1
+ import { LokiClient } from "./loki_client";
2
+ let _client = null;
3
+ /**
4
+ * Low-level client initializer. Prefer `initTelemetry` from the telemetry index, which also
5
+ * wires the SQLite outbox and the flush cron. Use this directly only in tests.
6
+ */
7
+ export function initLokiClient(config, queue, timeoutMs) {
8
+ _client = new LokiClient(config, queue, timeoutMs);
9
+ }
10
+ /** Returns the active LokiClient, or null if telemetry was not initialized. */
11
+ export function getLokiClient() {
12
+ return _client;
13
+ }
14
+ /** @internal — test use only */
15
+ export function _resetLokiClient() {
16
+ _client = null;
17
+ }
@@ -0,0 +1,35 @@
1
+ import type { ConnectivityState } from "./connectivity_state";
2
+ import type { FlushTelemetry } from "./flush_telemetry";
3
+ import type { TelemetrySignal } from "./telemetry_signal";
4
+ /** Default background-flush interval (minutes). ≥15 → real OS background task. */
5
+ export declare const DEFAULT_BACKGROUND_INTERVAL_MIN = 15;
6
+ export interface FlushWorkerOptions {
7
+ /** Background cron interval in minutes (≥15 → OS background task). 0 disables it. */
8
+ backgroundIntervalMin?: number;
9
+ /** Subscribe to InternetIsUp/Down events when the host app emits them. */
10
+ onConnectivityEvents?: boolean;
11
+ }
12
+ /**
13
+ * Orchestrates telemetry delivery from three independent triggers, all funneling
14
+ * into the same {@link FlushTelemetry} use case — none of them required:
15
+ *
16
+ * 1. Production signal (intrinsic, always on): producing telemetry schedules a
17
+ * debounced flush. This is the primary path and needs no cron or events.
18
+ * 2. Connectivity events (opt-in): if the host app emits InternetIsUp/Down,
19
+ * reconnecting triggers an immediate backlog drain.
20
+ * 3. Background cron (opt-in): a long-interval OS background task probes and
21
+ * drains, as a safety net for whatever the other two missed.
22
+ *
23
+ * The framework never *depends* on the connectivity cron or the event bus.
24
+ */
25
+ export declare class TelemetryFlushWorker {
26
+ private readonly flush;
27
+ private readonly signal;
28
+ private readonly connectivity;
29
+ private readonly opts;
30
+ private cron;
31
+ private unsubscribe;
32
+ constructor(flush: FlushTelemetry, signal: TelemetrySignal, connectivity: ConnectivityState, opts?: FlushWorkerOptions);
33
+ start(): void;
34
+ stop(): Promise<void>;
35
+ }
@@ -0,0 +1,66 @@
1
+ import { InternetIsDownEvent, InternetIsUpEvent, } from "../../domain/connectivity/events";
2
+ import { EBuiltinCron } from "../../entrypoints/cron/EBuiltinCron";
3
+ import { UIEventBus } from "../../infrastructure/ui/UIEventBus";
4
+ import { CronWorker } from "../../infrastructure/workers";
5
+ /** Default background-flush interval (minutes). ≥15 → real OS background task. */
6
+ export const DEFAULT_BACKGROUND_INTERVAL_MIN = 15;
7
+ /**
8
+ * Orchestrates telemetry delivery from three independent triggers, all funneling
9
+ * into the same {@link FlushTelemetry} use case — none of them required:
10
+ *
11
+ * 1. Production signal (intrinsic, always on): producing telemetry schedules a
12
+ * debounced flush. This is the primary path and needs no cron or events.
13
+ * 2. Connectivity events (opt-in): if the host app emits InternetIsUp/Down,
14
+ * reconnecting triggers an immediate backlog drain.
15
+ * 3. Background cron (opt-in): a long-interval OS background task probes and
16
+ * drains, as a safety net for whatever the other two missed.
17
+ *
18
+ * The framework never *depends* on the connectivity cron or the event bus.
19
+ */
20
+ export class TelemetryFlushWorker {
21
+ flush;
22
+ signal;
23
+ connectivity;
24
+ opts;
25
+ cron = null;
26
+ unsubscribe = null;
27
+ constructor(flush, signal, connectivity, opts = {}) {
28
+ this.flush = flush;
29
+ this.signal = signal;
30
+ this.connectivity = connectivity;
31
+ this.opts = opts;
32
+ }
33
+ start() {
34
+ // Trigger 2 — reconnect events (opt-in). Up → drain now (reset backoff);
35
+ // Down → mark offline (informational; delivery still self-recovers via backoff).
36
+ if (this.opts.onConnectivityEvents) {
37
+ const offUp = UIEventBus.on(InternetIsUpEvent.name, () => {
38
+ this.connectivity.markOnline();
39
+ this.flush.run({ resetBackoff: true }).catch(() => { });
40
+ });
41
+ const offDown = UIEventBus.on(InternetIsDownEvent.name, () => {
42
+ this.connectivity.markOffline();
43
+ });
44
+ this.unsubscribe = () => {
45
+ offUp();
46
+ offDown();
47
+ };
48
+ }
49
+ // Trigger 3 — background safety net (opt-in). Resets backoff so a long
50
+ // offline backlog drains promptly when the OS runs the task.
51
+ const intervalMin = this.opts.backgroundIntervalMin ?? DEFAULT_BACKGROUND_INTERVAL_MIN;
52
+ if (intervalMin > 0) {
53
+ this.cron = new CronWorker(EBuiltinCron.TELEMETRY_FLUSH, () => this.flush.run({ resetBackoff: true }), intervalMin);
54
+ this.cron.start().catch(() => { });
55
+ }
56
+ }
57
+ async stop() {
58
+ this.signal.dispose();
59
+ this.unsubscribe?.();
60
+ this.unsubscribe = null;
61
+ if (this.cron) {
62
+ await this.cron.unregister().catch(() => { });
63
+ this.cron = null;
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,18 @@
1
+ /** Default coalescing window for production-triggered flushes. */
2
+ export declare const DEFAULT_SIGNAL_DEBOUNCE_MS = 3000;
3
+ /**
4
+ * In-memory, debounced trigger. Telemetry is produced far faster than it should
5
+ * be shipped, so a burst of `notify()` calls collapses into a single `onFlush`
6
+ * after a quiet window. This is the production trigger — intrinsic to producing
7
+ * telemetry, with no dependency on crons or the event bus.
8
+ */
9
+ export declare class TelemetrySignal {
10
+ private readonly onFlush;
11
+ private readonly debounceMs;
12
+ private timer;
13
+ constructor(onFlush: () => void, debounceMs?: number);
14
+ /** Schedules a single flush; further calls within the window are coalesced. */
15
+ notify(): void;
16
+ /** Cancels a pending flush (used on teardown / re-init). */
17
+ dispose(): void;
18
+ }
@@ -0,0 +1,33 @@
1
+ /** Default coalescing window for production-triggered flushes. */
2
+ export const DEFAULT_SIGNAL_DEBOUNCE_MS = 3000;
3
+ /**
4
+ * In-memory, debounced trigger. Telemetry is produced far faster than it should
5
+ * be shipped, so a burst of `notify()` calls collapses into a single `onFlush`
6
+ * after a quiet window. This is the production trigger — intrinsic to producing
7
+ * telemetry, with no dependency on crons or the event bus.
8
+ */
9
+ export class TelemetrySignal {
10
+ onFlush;
11
+ debounceMs;
12
+ timer = null;
13
+ constructor(onFlush, debounceMs = DEFAULT_SIGNAL_DEBOUNCE_MS) {
14
+ this.onFlush = onFlush;
15
+ this.debounceMs = debounceMs;
16
+ }
17
+ /** Schedules a single flush; further calls within the window are coalesced. */
18
+ notify() {
19
+ if (this.timer)
20
+ return; // a flush is already scheduled — coalesce
21
+ this.timer = setTimeout(() => {
22
+ this.timer = null;
23
+ this.onFlush();
24
+ }, this.debounceMs);
25
+ }
26
+ /** Cancels a pending flush (used on teardown / re-init). */
27
+ dispose() {
28
+ if (this.timer) {
29
+ clearTimeout(this.timer);
30
+ this.timer = null;
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,22 @@
1
+ import type { SpanContext } from "@opentelemetry/api";
2
+ /**
3
+ * Returns the SpanContext (traceId + spanId + traceFlags) of the span
4
+ * currently active in the framework context, or null if no span is active.
5
+ *
6
+ * Use this to correlate logs with traces — inject traceId/spanId into log
7
+ * lines so Grafana can link from Loki to Tempo automatically.
8
+ *
9
+ * @example
10
+ * const span = getActiveSpanContext();
11
+ * logger.info(`payment processed trace_id=${span?.traceId ?? "-"}`);
12
+ */
13
+ export declare function getActiveSpanContext(): SpanContext | null;
14
+ /**
15
+ * Formats the active trace context as a log suffix ready to append to any
16
+ * log line. Returns an empty string when no span is active so callers don't
17
+ * need to branch.
18
+ *
19
+ * Output: " trace_id=<id> span_id=<id>"
20
+ * Grafana Derived Fields can extract these and link to Tempo automatically.
21
+ */
22
+ export declare function activeTraceLabel(): string;
@@ -0,0 +1,40 @@
1
+ import { trace } from "@opentelemetry/api";
2
+ import { _getContextManager } from "../../context_manager/index";
3
+ import { OTEL_CTX_KEY } from "../context_keys";
4
+ /**
5
+ * Returns the SpanContext (traceId + spanId + traceFlags) of the span
6
+ * currently active in the framework context, or null if no span is active.
7
+ *
8
+ * Use this to correlate logs with traces — inject traceId/spanId into log
9
+ * lines so Grafana can link from Loki to Tempo automatically.
10
+ *
11
+ * @example
12
+ * const span = getActiveSpanContext();
13
+ * logger.info(`payment processed trace_id=${span?.traceId ?? "-"}`);
14
+ */
15
+ export function getActiveSpanContext() {
16
+ const otelCtx = _getContextManager().active().get(OTEL_CTX_KEY);
17
+ if (!otelCtx)
18
+ return null;
19
+ const span = trace.getSpan(otelCtx);
20
+ if (!span)
21
+ return null;
22
+ const ctx = span.spanContext();
23
+ // A NonRecordingSpan has a valid-looking spanContext but isRemote/sampled may
24
+ // differ. We only return it if the span is actually recording (sampled).
25
+ return span.isRecording() ? ctx : null;
26
+ }
27
+ /**
28
+ * Formats the active trace context as a log suffix ready to append to any
29
+ * log line. Returns an empty string when no span is active so callers don't
30
+ * need to branch.
31
+ *
32
+ * Output: " trace_id=<id> span_id=<id>"
33
+ * Grafana Derived Fields can extract these and link to Tempo automatically.
34
+ */
35
+ export function activeTraceLabel() {
36
+ const ctx = getActiveSpanContext();
37
+ if (!ctx)
38
+ return "";
39
+ return ` trace_id=${ctx.traceId} span_id=${ctx.spanId}`;
40
+ }
@@ -0,0 +1,13 @@
1
+ import type { OtlpConfig } from "../types";
2
+ import type { SpanRow } from "./span_queue_repository";
3
+ /**
4
+ * Sends a batch of spans to an OTLP/HTTP collector (Grafana Alloy, Jaeger, etc.).
5
+ * Throws on non-2xx so the caller (span_flush_job) can apply backoff.
6
+ */
7
+ export declare class OtlpClient {
8
+ private readonly config;
9
+ private readonly timeoutMs;
10
+ constructor(config: OtlpConfig, timeoutMs?: number);
11
+ deliver(spans: SpanRow[]): Promise<void>;
12
+ private requestHeaders;
13
+ }
@@ -0,0 +1,34 @@
1
+ import { DEFAULT_SEND_TIMEOUT_MS, fetchWithTimeout } from "../fetch_with_timeout";
2
+ import { serializeToOtlp } from "./otlp_serializer";
3
+ /**
4
+ * Sends a batch of spans to an OTLP/HTTP collector (Grafana Alloy, Jaeger, etc.).
5
+ * Throws on non-2xx so the caller (span_flush_job) can apply backoff.
6
+ */
7
+ export class OtlpClient {
8
+ config;
9
+ timeoutMs;
10
+ constructor(config, timeoutMs = DEFAULT_SEND_TIMEOUT_MS) {
11
+ this.config = config;
12
+ this.timeoutMs = timeoutMs;
13
+ }
14
+ async deliver(spans) {
15
+ if (spans.length === 0)
16
+ return;
17
+ const body = JSON.stringify(serializeToOtlp(spans));
18
+ const url = `${this.config.endpoint.replace(/\/$/, "")}/v1/traces`;
19
+ const res = await fetchWithTimeout(url, {
20
+ method: "POST",
21
+ headers: this.requestHeaders(),
22
+ body,
23
+ }, this.timeoutMs);
24
+ if (!res.ok) {
25
+ throw new Error(`OTLP collector responded ${res.status}`);
26
+ }
27
+ }
28
+ requestHeaders() {
29
+ const custom = this.config.headers ?? {};
30
+ // Strip any content-type the caller may have set — JSON is always required for OTLP/HTTP.
31
+ const stripped = Object.fromEntries(Object.entries(custom).filter(([k]) => k.toLowerCase() !== "content-type"));
32
+ return { ...stripped, "Content-Type": "application/json" };
33
+ }
34
+ }
@@ -0,0 +1,67 @@
1
+ import type { SpanRow } from "./span_queue_repository";
2
+ type OtlpAnyValue = {
3
+ stringValue: string;
4
+ } | {
5
+ intValue: string;
6
+ } | {
7
+ doubleValue: number;
8
+ } | {
9
+ boolValue: boolean;
10
+ };
11
+ interface OtlpKeyValue {
12
+ key: string;
13
+ value: OtlpAnyValue;
14
+ }
15
+ interface OtlpEvent {
16
+ name: string;
17
+ timeUnixNano: string;
18
+ attributes: OtlpKeyValue[];
19
+ }
20
+ interface OtlpLink {
21
+ traceId: string;
22
+ spanId: string;
23
+ attributes: OtlpKeyValue[];
24
+ }
25
+ interface OtlpSpan {
26
+ traceId: string;
27
+ spanId: string;
28
+ parentSpanId?: string;
29
+ name: string;
30
+ kind: number;
31
+ startTimeUnixNano: string;
32
+ endTimeUnixNano: string;
33
+ attributes: OtlpKeyValue[];
34
+ events: OtlpEvent[];
35
+ links: OtlpLink[];
36
+ status: {
37
+ code: number;
38
+ message?: string;
39
+ };
40
+ }
41
+ interface OtlpScopeSpans {
42
+ scope: {
43
+ name: string;
44
+ };
45
+ spans: OtlpSpan[];
46
+ }
47
+ interface OtlpResourceSpans {
48
+ resource: {
49
+ attributes: OtlpKeyValue[];
50
+ };
51
+ scopeSpans: OtlpScopeSpans[];
52
+ }
53
+ export interface OtlpExportRequest {
54
+ resourceSpans: OtlpResourceSpans[];
55
+ }
56
+ /**
57
+ * Converts a batch of SpanRows (from SQLite) into an OTLP/HTTP JSON payload.
58
+ *
59
+ * Grouping strategy: spans sharing the same `resource_attrs` JSON are placed
60
+ * under the same ResourceSpans entry. All spans are placed under a single
61
+ * scope named "@sincpro/mobile".
62
+ *
63
+ * traceId and spanId are hex strings — OTLP/JSON uses hex (not base64).
64
+ * Timestamps are nanosecond strings — already stored that way in SQLite.
65
+ */
66
+ export declare function serializeToOtlp(spans: SpanRow[]): OtlpExportRequest;
67
+ export {};