@sentry/roach 0.0.0-stage → 0.2.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/src/config.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `roach.json`: the run config of a repository. The GitHub Action
3
+ * (`action.ts`) and the vitest integration (`vitest.ts`) both read it, so a
4
+ * repository writes its origins and rules once, as data. See "Config file"
5
+ * in `README.md`.
6
+ */
7
+ import { access, readFile } from "node:fs/promises";
8
+ import path from "node:path";
9
+ import type { RecordingMode, RecordingRule, RunConfig } from "./types.ts";
10
+ import { VALUE_PATTERNS } from "./values.ts";
11
+
12
+ /** The Roach service of Sentry. See "Deploy" in `README.md`. */
13
+ export const DEFAULT_SERVICE_URL = "https://roach-proxy.getsentry.net";
14
+
15
+ /** The name of the config file. */
16
+ const FILE_NAME = "roach.json";
17
+
18
+ /**
19
+ * A changing value in `roach.json`: a regular expression source, or the
20
+ * name of a pattern in `VALUE_PATTERNS`, such as `{ "builtin": "uuid" }`.
21
+ */
22
+ type FileValue = string | { builtin: keyof typeof VALUE_PATTERNS };
23
+
24
+ /** A rule in `roach.json`. */
25
+ interface FileRule extends Omit<RecordingRule, "values"> {
26
+ values?: Record<string, FileValue>;
27
+ }
28
+
29
+ /** The content of `roach.json`. */
30
+ export interface RoachFile {
31
+ allow: string[];
32
+ rules: FileRule[];
33
+ /** Default: `GITHUB_REPOSITORY`. */
34
+ tenant?: string;
35
+ /**
36
+ * Environment variables that hold credentials, such as
37
+ * `ANTHROPIC_API_KEY`. The run never writes their values.
38
+ */
39
+ secretEnv?: string[];
40
+ /** Hosts that do not use the proxy. Default: loopback. */
41
+ noProxy?: string;
42
+ }
43
+
44
+ const MODES = new Set<string>(["auto", "off", "record", "replay"]);
45
+
46
+ /** A variable of `env`, or `undefined` when it is not set or is blank. */
47
+ export function readEnv(
48
+ name: string,
49
+ env: NodeJS.ProcessEnv = process.env,
50
+ ): string | undefined {
51
+ const value = env[name]?.trim();
52
+ return value === "" ? undefined : value;
53
+ }
54
+
55
+ /**
56
+ * Find `roach.json` in `from` or the closest parent directory, and read
57
+ * it. Throws when no directory has one.
58
+ */
59
+ export async function readRoachFile(
60
+ from = process.cwd(),
61
+ ): Promise<RoachFile & { path: string }> {
62
+ for (let directory = path.resolve(from); ;) {
63
+ const file = path.join(directory, FILE_NAME);
64
+ const found = await access(file).then(
65
+ () => true,
66
+ () => false,
67
+ );
68
+ if (found) {
69
+ return {
70
+ ...(JSON.parse(await readFile(file, "utf8")) as RoachFile),
71
+ path: file,
72
+ };
73
+ }
74
+ const parent = path.dirname(directory);
75
+ if (parent === directory) {
76
+ throw new Error(`Roach: no ${FILE_NAME} in ${from} or its parents`);
77
+ }
78
+ directory = parent;
79
+ }
80
+ }
81
+
82
+ function resolveValue(rule: string, name: string, value: FileValue): string {
83
+ if (typeof value === "string") return value;
84
+ const pattern = VALUE_PATTERNS[value.builtin];
85
+ if (!pattern) {
86
+ throw new Error(
87
+ `Roach: rule ${rule} value ${name} names no built-in pattern: ${value.builtin}`,
88
+ );
89
+ }
90
+ return pattern;
91
+ }
92
+
93
+ /**
94
+ * The run config of `file` in the environment `env`. Without the write
95
+ * token (`canWrite`), the mode is `replay`, as for forks. `ROACH_MODE`
96
+ * sets another mode.
97
+ */
98
+ export function runConfigOf(
99
+ file: RoachFile,
100
+ {
101
+ canWrite,
102
+ env = process.env,
103
+ }: { canWrite: boolean; env?: NodeJS.ProcessEnv },
104
+ ): RunConfig {
105
+ const mode = readEnv("ROACH_MODE", env) ?? (canWrite ? "auto" : "replay");
106
+ if (!MODES.has(mode)) {
107
+ throw new Error(
108
+ `Roach: ROACH_MODE must be auto, replay, record, or off, got ${mode}`,
109
+ );
110
+ }
111
+ const tenant = file.tenant ?? env.GITHUB_REPOSITORY;
112
+ if (!tenant) {
113
+ throw new Error(
114
+ `Roach: set tenant in ${FILE_NAME}, or run with GITHUB_REPOSITORY`,
115
+ );
116
+ }
117
+ const config: RunConfig = {
118
+ tenant,
119
+ mode: mode as RecordingMode,
120
+ allow: file.allow,
121
+ rules: file.rules.map(({ values, ...rule }) =>
122
+ values
123
+ ? {
124
+ ...rule,
125
+ values: Object.fromEntries(
126
+ Object.entries(values).map(([name, value]) => [
127
+ name,
128
+ resolveValue(rule.name, name, value),
129
+ ]),
130
+ ),
131
+ }
132
+ : rule,
133
+ ),
134
+ secrets: (file.secretEnv ?? []).flatMap((name) => readEnv(name, env) ?? []),
135
+ };
136
+ const runId = readEnv("GITHUB_RUN_ID", env);
137
+ if (runId) {
138
+ config.name = `${runId}-${readEnv("GITHUB_RUN_ATTEMPT", env) ?? "1"}`;
139
+ }
140
+ return config;
141
+ }
package/src/gcs.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The GCS store: the recordings of the shared service (`service.ts`) in a
3
+ * Google Cloud Storage bucket.
4
+ *
5
+ * Each recording is the object `<prefix><key>`. A lifecycle rule of the
6
+ * bucket deletes it 30 days after it was written (`deploy/gcp/`), so
7
+ * nothing here deletes recordings.
8
+ *
9
+ * The store calls the JSON API with `fetch`, and takes its access token
10
+ * from the metadata server of the VM. So it needs no key file and no
11
+ * Google SDK. Two standard variables change the hosts, for local tests:
12
+ * `STORAGE_EMULATOR_HOST` (a URL) and `GCE_METADATA_HOST` (a host).
13
+ */
14
+ import {
15
+ formatRecording,
16
+ type Recording,
17
+ type RecordingStore,
18
+ } from "./store.ts";
19
+
20
+ /** A call that takes longer than this fails. */
21
+ const TIMEOUT_MS = 30_000;
22
+ /** Tries of one call. GCS asks clients to retry 429 and 5xx. */
23
+ const TRIES = 3;
24
+ /** Get a new token when the current one expires within this time. */
25
+ const TOKEN_MARGIN_MS = 60_000;
26
+
27
+ const isTemporary = (status: number) => status === 429 || status >= 500;
28
+ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
29
+
30
+ /** `fetch`, with a timeout and retries of temporary failures. */
31
+ async function call(url: string, init: RequestInit): Promise<Response> {
32
+ for (let attempt = 1; ; attempt += 1) {
33
+ try {
34
+ const response = await fetch(url, {
35
+ ...init,
36
+ signal: AbortSignal.timeout(TIMEOUT_MS),
37
+ });
38
+ if (!isTemporary(response.status) || attempt === TRIES) return response;
39
+ await response.body?.cancel();
40
+ } catch (error) {
41
+ if (attempt === TRIES) throw error;
42
+ }
43
+ await wait(250 * 2 ** attempt);
44
+ }
45
+ }
46
+
47
+ /** The access token of the VM service account, from the metadata server. */
48
+ function createTokenSource(metadataHost: string): () => Promise<string> {
49
+ let cached: { token: string; expires: number } | undefined;
50
+ return async () => {
51
+ if (cached && cached.expires - TOKEN_MARGIN_MS > Date.now()) {
52
+ return cached.token;
53
+ }
54
+ const response = await call(
55
+ `http://${metadataHost}/computeMetadata/v1/instance/service-accounts/default/token`,
56
+ { headers: { "metadata-flavor": "Google" } },
57
+ );
58
+ if (!response.ok) {
59
+ throw new Error(
60
+ `GCE metadata token failed with HTTP ${response.status}: ${await response.text()}`,
61
+ );
62
+ }
63
+ const body = (await response.json()) as {
64
+ access_token: string;
65
+ expires_in: number;
66
+ };
67
+ cached = {
68
+ token: body.access_token,
69
+ expires: Date.now() + body.expires_in * 1000,
70
+ };
71
+ return cached.token;
72
+ };
73
+ }
74
+
75
+ /** Where the store sends its calls. */
76
+ interface GcsHosts {
77
+ /** The base URL of the storage API. */
78
+ storage: string;
79
+ /** The host of the metadata server. */
80
+ metadata: string;
81
+ }
82
+
83
+ /** The hosts of GCS, or the ones of the standard variables. */
84
+ function gcsHosts(env = process.env): GcsHosts {
85
+ return {
86
+ storage: (
87
+ env.STORAGE_EMULATOR_HOST ?? "https://storage.googleapis.com"
88
+ ).replace(/\/$/, ""),
89
+ metadata: env.GCE_METADATA_HOST ?? "metadata.google.internal",
90
+ };
91
+ }
92
+
93
+ /** The store of the recordings in `bucket`, under `prefix`. */
94
+ export function createGcsStore(bucket: string, prefix: string): RecordingStore {
95
+ const hosts = gcsHosts();
96
+ const token = createTokenSource(hosts.metadata);
97
+ const bucketPath = `b/${encodeURIComponent(bucket)}/o`;
98
+
99
+ const writeOne = async ([key, recording]: [string, Recording]) => {
100
+ const name = encodeURIComponent(`${prefix}${key}`);
101
+ const response = await call(
102
+ `${hosts.storage}/upload/storage/v1/${bucketPath}?uploadType=media&name=${name}`,
103
+ {
104
+ method: "POST",
105
+ headers: {
106
+ authorization: `Bearer ${await token()}`,
107
+ "content-type": "application/json",
108
+ },
109
+ body: formatRecording(recording),
110
+ },
111
+ );
112
+ if (!response.ok) {
113
+ throw new Error(
114
+ `GCS write of ${key} failed with HTTP ${response.status}: ${await response.text()}`,
115
+ );
116
+ }
117
+ await response.body?.cancel();
118
+ };
119
+
120
+ return {
121
+ async read(key) {
122
+ const name = encodeURIComponent(`${prefix}${key}`);
123
+ const response = await call(
124
+ `${hosts.storage}/storage/v1/${bucketPath}/${name}?alt=media`,
125
+ { headers: { authorization: `Bearer ${await token()}` } },
126
+ );
127
+ if (response.status === 404) {
128
+ await response.body?.cancel();
129
+ return undefined;
130
+ }
131
+ if (!response.ok) {
132
+ throw new Error(
133
+ `GCS read of ${key} failed with HTTP ${response.status}: ${await response.text()}`,
134
+ );
135
+ }
136
+ return (await response.json()) as Recording;
137
+ },
138
+
139
+ // GCS does not say if an object changed, so each write counts.
140
+ async write(recordings) {
141
+ await Promise.all(recordings.map(writeOne));
142
+ return recordings.length;
143
+ },
144
+ };
145
+ }