tuft-telemetry 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/dist/expo-adapters.d.ts +107 -0
- package/dist/expo-adapters.js +161 -0
- package/dist/expo-router.d.ts +2 -0
- package/dist/expo-router.js +5 -0
- package/dist/expo.d.ts +6 -0
- package/dist/expo.js +49 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.js +308 -0
- package/docs/limitations.md +5 -0
- package/docs/privacy.md +5 -0
- package/docs/protocol.md +5 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# `tuft-telemetry`
|
|
2
|
+
|
|
3
|
+
An Expo-first flight recorder for coding agents. It owns Expo context, a durable journal, installation identity, lifecycle/network state, JavaScript error capture, route breadcrumbs, delivery, and cleanup.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
The `endpoint` is the collector's base machine URL. The SDK appends `/v1/events`. The bearer token authorizes exactly one stream; the app never selects or sends a stream ID.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createExpoTelemetry } from "tuft-telemetry/expo";
|
|
11
|
+
|
|
12
|
+
export const telemetry = createExpoTelemetry({
|
|
13
|
+
endpoint: process.env.EXPO_PUBLIC_TUFT_TELEMETRY_URL!,
|
|
14
|
+
token: process.env.EXPO_PUBLIC_TUFT_TELEMETRY_WRITE_TOKEN!,
|
|
15
|
+
});
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Initialization persists an installation ID, emits `app.launch` with build/update/device context, installs lifecycle/network listeners, chains the React Native global error handler, and tracks unhandled rejections. `shutdown()` restores installed handlers/listeners.
|
|
19
|
+
|
|
20
|
+
Expo Router is an optional entry point so non-Router apps still bundle:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { installExpoRouterTelemetry } from "tuft-telemetry/expo-router";
|
|
24
|
+
|
|
25
|
+
export const removeRouterTelemetry = installExpoRouterTelemetry(telemetry);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Instrument causal phases rather than every function call:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
const span = telemetry.startSpan("message.open", { source: "inbox" });
|
|
32
|
+
try {
|
|
33
|
+
span.event("cache.checked", { hit });
|
|
34
|
+
const message = await loadMessage();
|
|
35
|
+
span.end({ bodyBytes: message.bodyBytes });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
span.fail(error, { phase: "network" });
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Lower-level constructors and adapters remain exported for tests and custom integrations. See [the protocol](docs/protocol.md), [privacy model](docs/privacy.md), and [limitations](docs/limitations.md).
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { type Journal, type TelemetryClient } from "./index.js";
|
|
2
|
+
export interface ExpoFile {
|
|
3
|
+
exists: boolean;
|
|
4
|
+
text(): Promise<string>;
|
|
5
|
+
write(data: string): void;
|
|
6
|
+
delete(): void;
|
|
7
|
+
move(destination: ExpoFile): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export type ExpoFileFactory = (name: string) => ExpoFile;
|
|
10
|
+
export declare function createExpoFileJournal(file: ExpoFileFactory): Journal;
|
|
11
|
+
export declare function loadOrCreateInstallationId(metadata: {
|
|
12
|
+
exists: boolean;
|
|
13
|
+
textSync(): string;
|
|
14
|
+
write(data: string): void;
|
|
15
|
+
}, generate: () => string): string;
|
|
16
|
+
type Subscription = {
|
|
17
|
+
remove(): void;
|
|
18
|
+
};
|
|
19
|
+
export interface AppStateLike {
|
|
20
|
+
currentState: string;
|
|
21
|
+
addEventListener(event: "change", listener: (state: string) => void): Subscription;
|
|
22
|
+
}
|
|
23
|
+
export interface NetworkLike {
|
|
24
|
+
getNetworkStateAsync(): Promise<{
|
|
25
|
+
type?: string;
|
|
26
|
+
isConnected?: boolean;
|
|
27
|
+
isInternetReachable?: boolean;
|
|
28
|
+
}>;
|
|
29
|
+
addNetworkStateListener(listener: (state: {
|
|
30
|
+
type?: string;
|
|
31
|
+
isConnected?: boolean;
|
|
32
|
+
isInternetReachable?: boolean;
|
|
33
|
+
}) => void): Subscription;
|
|
34
|
+
}
|
|
35
|
+
export declare function installExpoLifecycle(client: TelemetryClient, modules: {
|
|
36
|
+
appState: AppStateLike;
|
|
37
|
+
network?: NetworkLike;
|
|
38
|
+
}): () => void;
|
|
39
|
+
export declare function collectExpoContext(modules: {
|
|
40
|
+
application?: {
|
|
41
|
+
applicationId?: string | null;
|
|
42
|
+
nativeApplicationVersion?: string | null;
|
|
43
|
+
nativeBuildVersion?: string | null;
|
|
44
|
+
};
|
|
45
|
+
updates?: {
|
|
46
|
+
updateId?: string | null;
|
|
47
|
+
runtimeVersion?: string | null;
|
|
48
|
+
channel?: string | null;
|
|
49
|
+
createdAt?: Date | null;
|
|
50
|
+
isEmbeddedLaunch?: boolean | null;
|
|
51
|
+
isEmergencyLaunch?: boolean | null;
|
|
52
|
+
};
|
|
53
|
+
device?: {
|
|
54
|
+
isDevice?: boolean;
|
|
55
|
+
modelName?: string | null;
|
|
56
|
+
osName?: string | null;
|
|
57
|
+
osVersion?: string | null;
|
|
58
|
+
};
|
|
59
|
+
platform?: string;
|
|
60
|
+
}): {
|
|
61
|
+
app: {
|
|
62
|
+
applicationId: string | undefined;
|
|
63
|
+
version: string | undefined;
|
|
64
|
+
buildVersion: string | undefined;
|
|
65
|
+
};
|
|
66
|
+
runtime: {
|
|
67
|
+
platform: string | undefined;
|
|
68
|
+
updateId: string | undefined;
|
|
69
|
+
runtimeVersion: string | undefined;
|
|
70
|
+
updateChannel: string | undefined;
|
|
71
|
+
updateCreatedAt: string | undefined;
|
|
72
|
+
isEmbeddedUpdate: boolean | undefined;
|
|
73
|
+
isEmergencyLaunch: boolean | undefined;
|
|
74
|
+
};
|
|
75
|
+
device: {
|
|
76
|
+
isDevice: boolean | undefined;
|
|
77
|
+
model: string | undefined;
|
|
78
|
+
osName: string | undefined;
|
|
79
|
+
osVersion: string | undefined;
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
type ErrorUtilsLike = {
|
|
83
|
+
getGlobalHandler(): ((error: Error, fatal?: boolean) => void) | undefined;
|
|
84
|
+
setGlobalHandler(handler: (error: Error, fatal?: boolean) => void): void;
|
|
85
|
+
};
|
|
86
|
+
type RejectionTrackingOptions = {
|
|
87
|
+
allRejections: boolean;
|
|
88
|
+
onUnhandled(id: number, error: unknown): void;
|
|
89
|
+
};
|
|
90
|
+
export type RejectionTrackingLike = {
|
|
91
|
+
enable(options: RejectionTrackingOptions): void;
|
|
92
|
+
disable(): void;
|
|
93
|
+
};
|
|
94
|
+
type ErrorTarget = typeof globalThis & {
|
|
95
|
+
ErrorUtils?: ErrorUtilsLike;
|
|
96
|
+
HermesInternal?: {
|
|
97
|
+
enablePromiseRejectionTracker?(options: RejectionTrackingOptions): void;
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
export declare function installAutomaticErrors(client: TelemetryClient, target?: ErrorTarget, rejectionTracking?: RejectionTrackingLike): () => void;
|
|
101
|
+
export type RouterLike = {
|
|
102
|
+
push?: (href: unknown, options?: unknown) => unknown;
|
|
103
|
+
replace?: (href: unknown, options?: unknown) => unknown;
|
|
104
|
+
navigate?: (href: unknown, options?: unknown) => unknown;
|
|
105
|
+
};
|
|
106
|
+
export declare function installExpoRouterBreadcrumbs(client: TelemetryClient, router?: RouterLike): () => void;
|
|
107
|
+
export {};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @ref LLP 0081#one-call-expo-sdk-boundary
|
|
2
|
+
import { normalizeRoute, } from "./index.js";
|
|
3
|
+
export function createExpoFileJournal(file) {
|
|
4
|
+
return {
|
|
5
|
+
async load() {
|
|
6
|
+
for (const candidate of [file("events-v1.json"), file("events-v1.tmp")]) {
|
|
7
|
+
if (!candidate.exists)
|
|
8
|
+
continue;
|
|
9
|
+
try {
|
|
10
|
+
const value = JSON.parse(await candidate.text());
|
|
11
|
+
if (Array.isArray(value))
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
catch { }
|
|
15
|
+
}
|
|
16
|
+
return [];
|
|
17
|
+
},
|
|
18
|
+
async replace(records) {
|
|
19
|
+
const primary = file("events-v1.json");
|
|
20
|
+
const temporary = file("events-v1.tmp");
|
|
21
|
+
if (temporary.exists)
|
|
22
|
+
temporary.delete();
|
|
23
|
+
temporary.write(JSON.stringify(records));
|
|
24
|
+
if (primary.exists)
|
|
25
|
+
primary.delete();
|
|
26
|
+
await temporary.move(primary);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function loadOrCreateInstallationId(metadata, generate) {
|
|
31
|
+
if (metadata.exists) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(metadata.textSync());
|
|
34
|
+
if (typeof parsed.installationId === "string" && parsed.installationId)
|
|
35
|
+
return parsed.installationId;
|
|
36
|
+
}
|
|
37
|
+
catch { }
|
|
38
|
+
}
|
|
39
|
+
const installationId = generate();
|
|
40
|
+
metadata.write(JSON.stringify({ schemaVersion: 1, installationId }));
|
|
41
|
+
return installationId;
|
|
42
|
+
}
|
|
43
|
+
export function installExpoLifecycle(client, modules) {
|
|
44
|
+
let lastAppState = modules.appState.currentState, online = true;
|
|
45
|
+
const app = modules.appState.addEventListener("change", (next) => {
|
|
46
|
+
if (next === lastAppState)
|
|
47
|
+
return;
|
|
48
|
+
client.breadcrumb("app.state.changed", { from: lastAppState, to: next });
|
|
49
|
+
lastAppState = next;
|
|
50
|
+
if (next === "background")
|
|
51
|
+
void client.flush({ timeoutMs: 1_500 });
|
|
52
|
+
if (next === "active" && online)
|
|
53
|
+
void client.flush();
|
|
54
|
+
});
|
|
55
|
+
let network;
|
|
56
|
+
if (modules.network) {
|
|
57
|
+
network = modules.network.addNetworkStateListener((state) => {
|
|
58
|
+
const next = state.isConnected !== false;
|
|
59
|
+
client.breadcrumb("network.state.changed", {
|
|
60
|
+
type: state.type ?? null,
|
|
61
|
+
connected: state.isConnected ?? null,
|
|
62
|
+
internetReachable: state.isInternetReachable ?? null,
|
|
63
|
+
});
|
|
64
|
+
client.setOnline(next);
|
|
65
|
+
online = next;
|
|
66
|
+
});
|
|
67
|
+
void modules.network.getNetworkStateAsync().then((initial) => {
|
|
68
|
+
online = initial.isConnected !== false;
|
|
69
|
+
client.setOnline(online);
|
|
70
|
+
}, () => { });
|
|
71
|
+
}
|
|
72
|
+
return () => {
|
|
73
|
+
app.remove();
|
|
74
|
+
network?.remove();
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function collectExpoContext(modules) {
|
|
78
|
+
return {
|
|
79
|
+
app: {
|
|
80
|
+
applicationId: modules.application?.applicationId ?? undefined,
|
|
81
|
+
version: modules.application?.nativeApplicationVersion ?? undefined,
|
|
82
|
+
buildVersion: modules.application?.nativeBuildVersion ?? undefined,
|
|
83
|
+
},
|
|
84
|
+
runtime: {
|
|
85
|
+
platform: modules.platform,
|
|
86
|
+
updateId: modules.updates?.updateId ?? undefined,
|
|
87
|
+
runtimeVersion: modules.updates?.runtimeVersion ?? undefined,
|
|
88
|
+
updateChannel: modules.updates?.channel ?? undefined,
|
|
89
|
+
updateCreatedAt: modules.updates?.createdAt?.toISOString(),
|
|
90
|
+
isEmbeddedUpdate: modules.updates?.isEmbeddedLaunch ?? undefined,
|
|
91
|
+
isEmergencyLaunch: modules.updates?.isEmergencyLaunch ?? undefined,
|
|
92
|
+
},
|
|
93
|
+
device: {
|
|
94
|
+
isDevice: modules.device?.isDevice,
|
|
95
|
+
model: modules.device?.modelName ?? undefined,
|
|
96
|
+
osName: modules.device?.osName ?? undefined,
|
|
97
|
+
osVersion: modules.device?.osVersion ?? undefined,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function installAutomaticErrors(client, target = globalThis, rejectionTracking) {
|
|
102
|
+
const errorUtils = target.ErrorUtils, previous = errorUtils?.getGlobalHandler();
|
|
103
|
+
const handler = (error, fatal) => {
|
|
104
|
+
client.error("javascript.uncaught", error, { fatal: fatal ?? false });
|
|
105
|
+
void client.flush({ timeoutMs: 1_500 });
|
|
106
|
+
previous?.(error, fatal);
|
|
107
|
+
};
|
|
108
|
+
errorUtils?.setGlobalHandler(handler);
|
|
109
|
+
const captureRejection = (_id, error) => client.error("javascript.unhandled_rejection", error);
|
|
110
|
+
const rejection = (event) => captureRejection(0, event.reason);
|
|
111
|
+
let disableRejectionTracking;
|
|
112
|
+
if (target.addEventListener) {
|
|
113
|
+
target.addEventListener("unhandledrejection", rejection);
|
|
114
|
+
disableRejectionTracking = () => target.removeEventListener("unhandledrejection", rejection);
|
|
115
|
+
}
|
|
116
|
+
else if (target.HermesInternal?.enablePromiseRejectionTracker) {
|
|
117
|
+
target.HermesInternal.enablePromiseRejectionTracker({
|
|
118
|
+
allRejections: true,
|
|
119
|
+
onUnhandled: captureRejection,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
else if (rejectionTracking) {
|
|
123
|
+
rejectionTracking.enable({
|
|
124
|
+
allRejections: true,
|
|
125
|
+
onUnhandled: captureRejection,
|
|
126
|
+
});
|
|
127
|
+
disableRejectionTracking = () => rejectionTracking.disable();
|
|
128
|
+
}
|
|
129
|
+
return () => {
|
|
130
|
+
if (errorUtils?.getGlobalHandler() === handler && previous)
|
|
131
|
+
errorUtils.setGlobalHandler(previous);
|
|
132
|
+
disableRejectionTracking?.();
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
export function installExpoRouterBreadcrumbs(client, router) {
|
|
136
|
+
if (!router)
|
|
137
|
+
return () => { };
|
|
138
|
+
const originals = new Map();
|
|
139
|
+
for (const method of ["push", "replace", "navigate"]) {
|
|
140
|
+
const original = router[method];
|
|
141
|
+
if (!original)
|
|
142
|
+
continue;
|
|
143
|
+
originals.set(method, original);
|
|
144
|
+
router[method] = ((href, options) => {
|
|
145
|
+
const path = typeof href === "string"
|
|
146
|
+
? href
|
|
147
|
+
: href && typeof href === "object" && "pathname" in href
|
|
148
|
+
? String(href.pathname)
|
|
149
|
+
: "unknown";
|
|
150
|
+
client.breadcrumb("navigation.changed", {
|
|
151
|
+
action: method,
|
|
152
|
+
route: normalizeRoute(path),
|
|
153
|
+
});
|
|
154
|
+
return original.call(router, href, options);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return () => {
|
|
158
|
+
for (const [method, original] of originals)
|
|
159
|
+
router[method] = original;
|
|
160
|
+
};
|
|
161
|
+
}
|
package/dist/expo.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ClientOptions, type TelemetryClient } from "./index.js";
|
|
2
|
+
export type CreateExpoTelemetryOptions = Pick<ClientOptions, "endpoint" | "token" | "enabled" | "redactKeys"> & {
|
|
3
|
+
flushLaunch?: boolean;
|
|
4
|
+
};
|
|
5
|
+
export declare function createExpoTelemetry(options: CreateExpoTelemetryOptions): TelemetryClient;
|
|
6
|
+
export * from "./expo-adapters.js";
|
package/dist/expo.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @ref LLP 0081#one-call-expo-sdk-boundary
|
|
2
|
+
import * as Application from "expo-application";
|
|
3
|
+
import * as Crypto from "expo-crypto";
|
|
4
|
+
import * as Device from "expo-device";
|
|
5
|
+
import { Directory, File, Paths } from "expo-file-system";
|
|
6
|
+
import * as Network from "expo-network";
|
|
7
|
+
import * as Updates from "expo-updates";
|
|
8
|
+
import * as RejectionTracking from "promise/setimmediate/rejection-tracking";
|
|
9
|
+
import { AppState, Platform } from "react-native";
|
|
10
|
+
import { createExpoFileJournal, collectExpoContext, installAutomaticErrors, installExpoLifecycle, loadOrCreateInstallationId, } from "./expo-adapters.js";
|
|
11
|
+
import { createTelemetryClient, } from "./index.js";
|
|
12
|
+
export function createExpoTelemetry(options) {
|
|
13
|
+
const directory = new Directory(Paths.document, "tuft-telemetry");
|
|
14
|
+
if (!directory.exists)
|
|
15
|
+
directory.create({ idempotent: true, intermediates: true });
|
|
16
|
+
const makeFile = (name) => new File(directory, name);
|
|
17
|
+
const metadata = new File(directory, "metadata-v1.json");
|
|
18
|
+
const persistInstallationId = async (next) => {
|
|
19
|
+
metadata.write(JSON.stringify({ schemaVersion: 1, installationId: next }));
|
|
20
|
+
};
|
|
21
|
+
const installationId = loadOrCreateInstallationId(metadata, Crypto.randomUUID);
|
|
22
|
+
const client = createTelemetryClient({
|
|
23
|
+
...options,
|
|
24
|
+
journal: createExpoFileJournal(makeFile),
|
|
25
|
+
installationId,
|
|
26
|
+
ids: Crypto.randomUUID,
|
|
27
|
+
persistInstallationId,
|
|
28
|
+
context: collectExpoContext({
|
|
29
|
+
application: Application,
|
|
30
|
+
updates: Updates,
|
|
31
|
+
device: Device,
|
|
32
|
+
platform: Platform.OS,
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
const cleanups = [
|
|
36
|
+
installExpoLifecycle(client, { appState: AppState, network: Network }),
|
|
37
|
+
installAutomaticErrors(client, globalThis, RejectionTracking),
|
|
38
|
+
];
|
|
39
|
+
const shutdown = client.shutdown.bind(client);
|
|
40
|
+
client.shutdown = async () => {
|
|
41
|
+
cleanups.forEach((cleanup) => cleanup());
|
|
42
|
+
await shutdown();
|
|
43
|
+
};
|
|
44
|
+
client.event("app.launch");
|
|
45
|
+
if (options.flushLaunch !== false)
|
|
46
|
+
void client.flush({ timeoutMs: 3_000 });
|
|
47
|
+
return client;
|
|
48
|
+
}
|
|
49
|
+
export * from "./expo-adapters.js";
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export type TelemetryValue = string | number | boolean | null;
|
|
2
|
+
export type TelemetryAttributes = Record<string, TelemetryValue>;
|
|
3
|
+
export type TelemetryKind = "event" | "error" | "breadcrumb" | "span_start" | "span_end" | "marker";
|
|
4
|
+
export interface TelemetryEnvelope {
|
|
5
|
+
schemaVersion: 1;
|
|
6
|
+
id: string;
|
|
7
|
+
timestamp: string;
|
|
8
|
+
sequence: number;
|
|
9
|
+
kind: TelemetryKind;
|
|
10
|
+
level: "debug" | "info" | "warn" | "error";
|
|
11
|
+
name: string;
|
|
12
|
+
attributes: TelemetryAttributes;
|
|
13
|
+
installationId: string;
|
|
14
|
+
launchId: string;
|
|
15
|
+
traceId?: string;
|
|
16
|
+
spanId?: string;
|
|
17
|
+
parentSpanId?: string;
|
|
18
|
+
app?: {
|
|
19
|
+
applicationId?: string;
|
|
20
|
+
version?: string;
|
|
21
|
+
buildVersion?: string;
|
|
22
|
+
};
|
|
23
|
+
runtime?: {
|
|
24
|
+
platform?: string;
|
|
25
|
+
route?: string;
|
|
26
|
+
updateId?: string;
|
|
27
|
+
runtimeVersion?: string;
|
|
28
|
+
updateChannel?: string;
|
|
29
|
+
updateCreatedAt?: string;
|
|
30
|
+
isEmbeddedUpdate?: boolean;
|
|
31
|
+
isEmergencyLaunch?: boolean;
|
|
32
|
+
};
|
|
33
|
+
device?: {
|
|
34
|
+
isDevice?: boolean;
|
|
35
|
+
model?: string;
|
|
36
|
+
osName?: string;
|
|
37
|
+
osVersion?: string;
|
|
38
|
+
};
|
|
39
|
+
error?: {
|
|
40
|
+
name?: string;
|
|
41
|
+
message: string;
|
|
42
|
+
stack?: string;
|
|
43
|
+
cause?: string;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export interface Journal {
|
|
47
|
+
load(): Promise<TelemetryEnvelope[]>;
|
|
48
|
+
replace(records: TelemetryEnvelope[]): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
export interface Span {
|
|
51
|
+
readonly traceId: string;
|
|
52
|
+
readonly spanId: string;
|
|
53
|
+
event(name: string, attributes?: TelemetryAttributes): void;
|
|
54
|
+
end(attributes?: TelemetryAttributes): void;
|
|
55
|
+
fail(error: unknown, attributes?: TelemetryAttributes): void;
|
|
56
|
+
}
|
|
57
|
+
export type FlushResult = {
|
|
58
|
+
status: "empty" | "uploaded" | "retrying" | "rejected" | "offline" | "suspended" | "timeout";
|
|
59
|
+
count: number;
|
|
60
|
+
retryAfterMs?: number;
|
|
61
|
+
};
|
|
62
|
+
export type DeliveryStatus = {
|
|
63
|
+
online: boolean;
|
|
64
|
+
suspended: boolean;
|
|
65
|
+
reason?: "authentication" | "configuration" | "shutdown";
|
|
66
|
+
queued: number;
|
|
67
|
+
lastRejection?: {
|
|
68
|
+
status: number;
|
|
69
|
+
count: number;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
export type ProblemMarker = {
|
|
73
|
+
code: string;
|
|
74
|
+
uploaded: boolean;
|
|
75
|
+
};
|
|
76
|
+
export interface TelemetryClient {
|
|
77
|
+
event(name: string, attributes?: TelemetryAttributes): void;
|
|
78
|
+
error(name: string, error: unknown, attributes?: TelemetryAttributes): void;
|
|
79
|
+
breadcrumb(name: string, attributes?: TelemetryAttributes): void;
|
|
80
|
+
startSpan(name: string, attributes?: TelemetryAttributes, parent?: Span): Span;
|
|
81
|
+
flush(options?: {
|
|
82
|
+
timeoutMs?: number;
|
|
83
|
+
}): Promise<FlushResult>;
|
|
84
|
+
mark(name?: string, attributes?: TelemetryAttributes): Promise<ProblemMarker>;
|
|
85
|
+
setOnline(online: boolean): void;
|
|
86
|
+
getStatus(): Promise<DeliveryStatus>;
|
|
87
|
+
clear(): Promise<void>;
|
|
88
|
+
resetInstallationId(): Promise<void>;
|
|
89
|
+
shutdown(): Promise<void>;
|
|
90
|
+
}
|
|
91
|
+
export interface ClientOptions {
|
|
92
|
+
endpoint: string;
|
|
93
|
+
token: string;
|
|
94
|
+
journal: Journal;
|
|
95
|
+
installationId?: string;
|
|
96
|
+
enabled?: boolean;
|
|
97
|
+
autoFlush?: boolean;
|
|
98
|
+
flushDelayMs?: number;
|
|
99
|
+
batchSize?: number;
|
|
100
|
+
maxEvents?: number;
|
|
101
|
+
maxBytes?: number;
|
|
102
|
+
ids?: () => string;
|
|
103
|
+
now?: () => number;
|
|
104
|
+
fetch?: typeof fetch;
|
|
105
|
+
context?: Pick<TelemetryEnvelope, "app" | "runtime" | "device">;
|
|
106
|
+
backoff?: {
|
|
107
|
+
baseMs?: number;
|
|
108
|
+
maxMs?: number;
|
|
109
|
+
jitter?: number;
|
|
110
|
+
};
|
|
111
|
+
redactKeys?: (string | RegExp)[];
|
|
112
|
+
persistInstallationId?: (id: string) => Promise<void>;
|
|
113
|
+
}
|
|
114
|
+
export declare function redactAttributes(input: TelemetryAttributes | Record<string, unknown>, extra?: (string | RegExp)[]): TelemetryAttributes;
|
|
115
|
+
export declare function normalizeRoute(path: string, template?: string): string;
|
|
116
|
+
export declare function normalizeError(value: unknown): TelemetryEnvelope["error"];
|
|
117
|
+
export declare function randomId(): `${string}-${string}-${string}-${string}-${string}`;
|
|
118
|
+
export declare function createTelemetryClient(options: ClientOptions): TelemetryClient;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
const SECRET = /authorization|cookie|token|password|secret|api[-_]?key|session(?:id|credential)?/i;
|
|
2
|
+
const encoder = new TextEncoder();
|
|
3
|
+
const isScalar = (value) => value === null || ["string", "number", "boolean"].includes(typeof value);
|
|
4
|
+
export function redactAttributes(input, extra = []) {
|
|
5
|
+
const out = {};
|
|
6
|
+
for (const [rawKey, rawValue] of Object.entries(input).slice(0, 32)) {
|
|
7
|
+
const key = rawKey.slice(0, 80);
|
|
8
|
+
const secret = SECRET.test(key) ||
|
|
9
|
+
extra.some((rule) => typeof rule === "string"
|
|
10
|
+
? rule.toLowerCase() === key.toLowerCase()
|
|
11
|
+
: ((rule.lastIndex = 0), rule.test(key)));
|
|
12
|
+
if (secret)
|
|
13
|
+
out[key] = "[REDACTED]";
|
|
14
|
+
else if (isScalar(rawValue))
|
|
15
|
+
out[key] =
|
|
16
|
+
typeof rawValue === "string" ? rawValue.slice(0, 2_000) : rawValue;
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
export function normalizeRoute(path, template) {
|
|
21
|
+
if (template)
|
|
22
|
+
return template.split(/[?#]/)[0];
|
|
23
|
+
return path
|
|
24
|
+
.split(/[?#]/)[0]
|
|
25
|
+
.split("/")
|
|
26
|
+
.map((part) => !part
|
|
27
|
+
? ""
|
|
28
|
+
: /^(\d+|[0-9a-f]{8,}|[A-Za-z0-9_-]{16,})$/i.test(part)
|
|
29
|
+
? ":segment"
|
|
30
|
+
: part)
|
|
31
|
+
.join("/");
|
|
32
|
+
}
|
|
33
|
+
export function normalizeError(value) {
|
|
34
|
+
if (!(value instanceof Error))
|
|
35
|
+
return { message: String(value).slice(0, 2_000) };
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
const causes = [];
|
|
38
|
+
let current = value.cause;
|
|
39
|
+
while (current != null && !seen.has(current) && causes.length < 5) {
|
|
40
|
+
seen.add(current);
|
|
41
|
+
causes.push(current instanceof Error ? current.message : String(current));
|
|
42
|
+
current = current instanceof Error ? current.cause : undefined;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
name: value.name.slice(0, 120),
|
|
46
|
+
message: value.message.slice(0, 2_000),
|
|
47
|
+
stack: value.stack?.slice(0, 16_000),
|
|
48
|
+
cause: causes.length ? causes.join(" <- ").slice(0, 2_000) : undefined,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function randomId() {
|
|
52
|
+
return (globalThis.crypto?.randomUUID?.() ??
|
|
53
|
+
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`);
|
|
54
|
+
}
|
|
55
|
+
function reportCode() {
|
|
56
|
+
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
57
|
+
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
|
|
58
|
+
}
|
|
59
|
+
function encodedBytes(value) {
|
|
60
|
+
return encoder.encode(JSON.stringify(value)).byteLength;
|
|
61
|
+
}
|
|
62
|
+
function trimRecords(records, maxEvents, maxBytes) {
|
|
63
|
+
let dropped = Math.max(0, records.length - maxEvents);
|
|
64
|
+
if (dropped)
|
|
65
|
+
records.splice(0, dropped);
|
|
66
|
+
let totalBytes = 2 + Math.max(0, records.length - 1);
|
|
67
|
+
const sizes = records.map((record) => encodedBytes(record));
|
|
68
|
+
totalBytes += sizes.reduce((sum, size) => sum + size, 0);
|
|
69
|
+
while (records.length && totalBytes > maxBytes) {
|
|
70
|
+
totalBytes -= sizes.shift() ?? 0;
|
|
71
|
+
if (records.length > 1)
|
|
72
|
+
totalBytes -= 1;
|
|
73
|
+
records.shift();
|
|
74
|
+
dropped++;
|
|
75
|
+
}
|
|
76
|
+
return dropped;
|
|
77
|
+
}
|
|
78
|
+
export function createTelemetryClient(options) {
|
|
79
|
+
const enabled = options.enabled ?? true, id = options.ids ?? randomId, now = options.now ?? Date.now, launchId = id();
|
|
80
|
+
let installationId = options.installationId ?? id();
|
|
81
|
+
let sequence = 0, closed = false, online = true, suspended = false, suspendReason, lastRejection;
|
|
82
|
+
let timer;
|
|
83
|
+
let journalQueue = Promise.resolve(), flushFlight, failures = 0;
|
|
84
|
+
let journalCache;
|
|
85
|
+
const maxEvents = options.maxEvents ?? 1_000, maxBytes = options.maxBytes ?? 2_000_000, batchSize = options.batchSize ?? 100;
|
|
86
|
+
const transaction = (work) => {
|
|
87
|
+
const next = journalQueue.then(work, work);
|
|
88
|
+
journalQueue = next.then(() => undefined, () => undefined);
|
|
89
|
+
return next;
|
|
90
|
+
};
|
|
91
|
+
const loadJournal = async () => {
|
|
92
|
+
journalCache ??= await options.journal.load();
|
|
93
|
+
return journalCache;
|
|
94
|
+
};
|
|
95
|
+
const read = () => transaction(async () => [...(await loadJournal())]);
|
|
96
|
+
const mutate = (work) => transaction(async () => {
|
|
97
|
+
const records = [...(await loadJournal())];
|
|
98
|
+
const next = await work(records);
|
|
99
|
+
await options.journal.replace(next);
|
|
100
|
+
journalCache = [...next];
|
|
101
|
+
return next;
|
|
102
|
+
});
|
|
103
|
+
const make = (name, kind, attributes = {}, level = "info", links = {}, error) => ({
|
|
104
|
+
schemaVersion: 1,
|
|
105
|
+
id: id(),
|
|
106
|
+
timestamp: new Date(now()).toISOString(),
|
|
107
|
+
sequence: ++sequence,
|
|
108
|
+
kind,
|
|
109
|
+
level,
|
|
110
|
+
name: name.slice(0, 160),
|
|
111
|
+
attributes: redactAttributes(attributes, options.redactKeys),
|
|
112
|
+
installationId,
|
|
113
|
+
launchId,
|
|
114
|
+
...options.context,
|
|
115
|
+
...links,
|
|
116
|
+
...(error === undefined ? {} : { error: normalizeError(error) }),
|
|
117
|
+
});
|
|
118
|
+
const schedule = (delay = options.flushDelayMs ?? 1_000, replace = false) => {
|
|
119
|
+
if (!enabled ||
|
|
120
|
+
closed ||
|
|
121
|
+
suspended ||
|
|
122
|
+
!online ||
|
|
123
|
+
options.autoFlush === false)
|
|
124
|
+
return;
|
|
125
|
+
if (timer && !replace)
|
|
126
|
+
return;
|
|
127
|
+
if (timer)
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
timer = setTimeout(() => {
|
|
130
|
+
timer = undefined;
|
|
131
|
+
void flush();
|
|
132
|
+
}, Math.max(0, delay));
|
|
133
|
+
};
|
|
134
|
+
const append = (record) => {
|
|
135
|
+
void mutate((records) => {
|
|
136
|
+
records.push(record);
|
|
137
|
+
let dropped = trimRecords(records, maxEvents, maxBytes);
|
|
138
|
+
if (dropped && record.name !== "telemetry.events_dropped") {
|
|
139
|
+
records.push(make("telemetry.events_dropped", "event", { count: dropped }, "warn"));
|
|
140
|
+
dropped += trimRecords(records, maxEvents, maxBytes);
|
|
141
|
+
}
|
|
142
|
+
return records;
|
|
143
|
+
});
|
|
144
|
+
schedule();
|
|
145
|
+
};
|
|
146
|
+
const record = (name, kind, attributes, level, links, error) => {
|
|
147
|
+
if (enabled && !closed)
|
|
148
|
+
append(make(name, kind, attributes, level, links, error));
|
|
149
|
+
};
|
|
150
|
+
const flush = (settings = {}) => {
|
|
151
|
+
if (closed && suspendReason === "shutdown")
|
|
152
|
+
return Promise.resolve({ status: "suspended", count: 0 });
|
|
153
|
+
if (suspended)
|
|
154
|
+
return Promise.resolve({ status: "suspended", count: 0 });
|
|
155
|
+
if (!online)
|
|
156
|
+
return Promise.resolve({ status: "offline", count: 0 });
|
|
157
|
+
if (flushFlight)
|
|
158
|
+
return flushFlight;
|
|
159
|
+
const flight = (async () => {
|
|
160
|
+
let uploaded = 0;
|
|
161
|
+
while (online && !suspended) {
|
|
162
|
+
const batch = (await read()).slice(0, batchSize);
|
|
163
|
+
if (!batch.length)
|
|
164
|
+
return { status: uploaded ? "uploaded" : "empty", count: uploaded };
|
|
165
|
+
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), settings.timeoutMs ?? 10_000);
|
|
166
|
+
try {
|
|
167
|
+
const response = await (options.fetch ?? globalThis.fetch)(`${options.endpoint.replace(/\/+$/, "")}/v1/events`, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
signal: controller.signal,
|
|
170
|
+
headers: {
|
|
171
|
+
authorization: `Bearer ${options.token}`,
|
|
172
|
+
"content-type": "application/json",
|
|
173
|
+
},
|
|
174
|
+
body: JSON.stringify({ schemaVersion: 1, events: batch }),
|
|
175
|
+
});
|
|
176
|
+
if (response.ok) {
|
|
177
|
+
const accepted = new Set(batch.map((item) => item.id));
|
|
178
|
+
await mutate((all) => all.filter((item) => !accepted.has(item.id)));
|
|
179
|
+
failures = 0;
|
|
180
|
+
uploaded += batch.length;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if ([401, 403].includes(response.status)) {
|
|
184
|
+
suspended = true;
|
|
185
|
+
suspendReason = "authentication";
|
|
186
|
+
return { status: "rejected", count: uploaded };
|
|
187
|
+
}
|
|
188
|
+
if (response.status === 400 || response.status === 422) {
|
|
189
|
+
const rejected = new Set(batch.map((item) => item.id));
|
|
190
|
+
await mutate((all) => all.filter((item) => !rejected.has(item.id)));
|
|
191
|
+
lastRejection = { status: response.status, count: batch.length };
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if ([408, 425, 429].includes(response.status) ||
|
|
195
|
+
response.status >= 500) {
|
|
196
|
+
failures++;
|
|
197
|
+
const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"), now(), backoffMs(failures, options.backoff));
|
|
198
|
+
schedule(retryAfterMs, true);
|
|
199
|
+
return { status: "retrying", count: uploaded, retryAfterMs };
|
|
200
|
+
}
|
|
201
|
+
suspended = true;
|
|
202
|
+
suspendReason = "configuration";
|
|
203
|
+
lastRejection = { status: response.status, count: batch.length };
|
|
204
|
+
return { status: "rejected", count: uploaded };
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
failures++;
|
|
208
|
+
const retryAfterMs = backoffMs(failures, options.backoff);
|
|
209
|
+
schedule(retryAfterMs, true);
|
|
210
|
+
return {
|
|
211
|
+
status: controller.signal.aborted ? "timeout" : "retrying",
|
|
212
|
+
count: uploaded,
|
|
213
|
+
retryAfterMs,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
clearTimeout(timeout);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { status: online ? "suspended" : "offline", count: uploaded };
|
|
221
|
+
})().finally(() => {
|
|
222
|
+
flushFlight = undefined;
|
|
223
|
+
});
|
|
224
|
+
flushFlight = flight;
|
|
225
|
+
return flight;
|
|
226
|
+
};
|
|
227
|
+
const startSpan = (name, attributes = {}, parent) => {
|
|
228
|
+
const traceId = parent?.traceId ?? id(), spanId = id(), started = globalThis.performance?.now?.() ?? now();
|
|
229
|
+
let ended = false;
|
|
230
|
+
const links = { traceId, spanId, parentSpanId: parent?.spanId };
|
|
231
|
+
record(name, "span_start", attributes, "info", links);
|
|
232
|
+
const finish = (level, attrs, error) => {
|
|
233
|
+
if (ended)
|
|
234
|
+
return;
|
|
235
|
+
ended = true;
|
|
236
|
+
record(name, "span_end", {
|
|
237
|
+
...attrs,
|
|
238
|
+
durationMs: Math.max(0, (globalThis.performance?.now?.() ?? now()) - started),
|
|
239
|
+
}, level, links, error);
|
|
240
|
+
};
|
|
241
|
+
return {
|
|
242
|
+
traceId,
|
|
243
|
+
spanId,
|
|
244
|
+
event: (eventName, attrs) => record(eventName, "event", attrs, "info", links),
|
|
245
|
+
end: (attrs) => finish("info", attrs),
|
|
246
|
+
fail: (error, attrs) => finish("error", attrs, error),
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
return {
|
|
250
|
+
event: (name, attrs) => record(name, "event", attrs),
|
|
251
|
+
error: (name, error, attrs) => record(name, "error", attrs, "error", {}, error),
|
|
252
|
+
breadcrumb: (name, attrs) => record(name, "breadcrumb", attrs, "debug"),
|
|
253
|
+
startSpan,
|
|
254
|
+
flush,
|
|
255
|
+
async mark(name = "problem.marked", attrs = {}) {
|
|
256
|
+
const code = reportCode();
|
|
257
|
+
record(name, "marker", { ...attrs, reportCode: code }, "warn");
|
|
258
|
+
const result = await flush({ timeoutMs: 3_000 });
|
|
259
|
+
return { code, uploaded: result.status === "uploaded" };
|
|
260
|
+
},
|
|
261
|
+
setOnline(next) {
|
|
262
|
+
const recovered = !online && next;
|
|
263
|
+
online = next;
|
|
264
|
+
if (!next && timer) {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
timer = undefined;
|
|
267
|
+
}
|
|
268
|
+
if (recovered)
|
|
269
|
+
schedule(0, true);
|
|
270
|
+
},
|
|
271
|
+
async getStatus() {
|
|
272
|
+
return {
|
|
273
|
+
online,
|
|
274
|
+
suspended,
|
|
275
|
+
reason: suspendReason,
|
|
276
|
+
queued: (await read()).length,
|
|
277
|
+
lastRejection,
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
async clear() {
|
|
281
|
+
await mutate(() => []);
|
|
282
|
+
},
|
|
283
|
+
async resetInstallationId() {
|
|
284
|
+
installationId = id();
|
|
285
|
+
await options.persistInstallationId?.(installationId);
|
|
286
|
+
},
|
|
287
|
+
async shutdown() {
|
|
288
|
+
if (timer)
|
|
289
|
+
clearTimeout(timer);
|
|
290
|
+
await flush({ timeoutMs: 3_000 });
|
|
291
|
+
closed = true;
|
|
292
|
+
suspended = true;
|
|
293
|
+
suspendReason = "shutdown";
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function backoffMs(failures, options = {}) {
|
|
298
|
+
const base = options.baseMs ?? 1_000, max = options.maxMs ?? 60_000, jitter = options.jitter ?? 0.2, raw = Math.min(max, base * 2 ** Math.max(0, failures - 1));
|
|
299
|
+
return Math.round(raw * (1 - jitter + Math.random() * jitter * 2));
|
|
300
|
+
}
|
|
301
|
+
function parseRetryAfter(value, now, fallback) {
|
|
302
|
+
if (!value)
|
|
303
|
+
return fallback;
|
|
304
|
+
if (/^\d+$/.test(value))
|
|
305
|
+
return Number(value) * 1_000;
|
|
306
|
+
const date = Date.parse(value);
|
|
307
|
+
return Number.isFinite(date) ? Math.max(0, date - now) : fallback;
|
|
308
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Limitations
|
|
2
|
+
|
|
3
|
+
This is not a native crash reporter. Native crashes, OOMs, ANRs, and process termination before a journal write completes can be invisible. Background execution and delivery are opportunistic on iOS and Android. Keep Sentry or a platform-native crash reporter for native failures; share launch, trace, build, and update IDs when bridging the two systems.
|
|
4
|
+
|
|
5
|
+
The current prototype provides the core recorder, delivery protocol, persistent installation identity, Expo file journal/context/lifecycle/network integration, JavaScript exception hooks, and normalized imperative Expo Router breadcrumbs. Update-log deduplication, notification/task helpers, declarative route observation for navigation that bypasses the imperative router, secure token storage, and on-device integration tests remain follow-on work before a production release.
|
package/docs/privacy.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Privacy and redaction
|
|
2
|
+
|
|
3
|
+
Only scalar attributes are accepted. Secret-shaped keys (`authorization`, `cookie`, `token`, `password`, `secret`, API keys, and session credentials) are replaced with `[REDACTED]`; applications can add exact keys or regular expressions. Names, keys, strings, stacks, attribute counts, event counts, and journal bytes are bounded. Routes drop query strings and fragments and replace ID-shaped segments.
|
|
4
|
+
|
|
5
|
+
Do not instrument bodies, headers, message/email contents, text input, clipboard data, arbitrary props, or URLs containing user data. Production enablement should be explicit and collector tokens must be write-only.
|
package/docs/protocol.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Collector protocol v1
|
|
2
|
+
|
|
3
|
+
The setup API supplies a base `collector_url`. The SDK sends `POST {collector_url}/v1/events` with `Authorization: Bearer <write-only token>` and JSON `{ "schemaVersion": 1, "events": [...] }`. The daemon resolves the one authorized stream from the token; clients cannot select a stream.
|
|
4
|
+
|
|
5
|
+
Any 2xx response acknowledges every event in that request. The SDK atomically removes those IDs and continues draining batches. `408`, `425`, `429`, `5xx`, network errors, and timeouts retain the batch and schedule retry; valid `Retry-After` values are honored. `401`/`403` suspend delivery with an authentication status. `400`/`422` drop the poison batch and record a local diagnostic so later valid events are not blocked. Other permanent responses suspend delivery with a configuration status and retain the batch for inspection. Collectors must ignore unknown envelope fields and deduplicate by event `id`.
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tuft-telemetry",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"dist",
|
|
7
|
+
"README.md",
|
|
8
|
+
"docs"
|
|
9
|
+
],
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./expo": {
|
|
18
|
+
"types": "./dist/expo.d.ts",
|
|
19
|
+
"import": "./dist/expo.js"
|
|
20
|
+
},
|
|
21
|
+
"./expo-router": {
|
|
22
|
+
"types": "./dist/expo-router.d.ts",
|
|
23
|
+
"import": "./dist/expo-router.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.json",
|
|
28
|
+
"prepack": "npm run build",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^24.0.0",
|
|
34
|
+
"typescript": "^5.8.3",
|
|
35
|
+
"vitest": "^3.2.4"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"promise": "^8.3.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"expo": "*",
|
|
42
|
+
"expo-application": "*",
|
|
43
|
+
"expo-crypto": "*",
|
|
44
|
+
"expo-device": "*",
|
|
45
|
+
"expo-file-system": "*",
|
|
46
|
+
"expo-network": "*",
|
|
47
|
+
"expo-router": "*",
|
|
48
|
+
"expo-updates": "*",
|
|
49
|
+
"react": "*",
|
|
50
|
+
"react-native": "*"
|
|
51
|
+
},
|
|
52
|
+
"peerDependenciesMeta": {
|
|
53
|
+
"expo-router": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|