bulletin-deploy 0.7.3 → 0.7.4

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 CHANGED
@@ -274,6 +274,32 @@ What's tracked:
274
274
  - Source metadata (repo, branch, PR number, CI vs local)
275
275
  - Tool version (`deploy.tool_version`)
276
276
 
277
+ ### Using bulletin-deploy as a library under your own Sentry
278
+
279
+ If your tool embeds bulletin-deploy as a library and already runs its own Sentry SDK (for example, Parity's `playground-cli`), you can route bulletin-deploy's deploy spans, tags, and diagnostics through your existing Sentry client rather than clobbering it with ours. Set two env vars **before** importing or invoking bulletin-deploy:
280
+
281
+ ```sh
282
+ BULLETIN_DEPLOY_USE_AMBIENT_SENTRY=1
283
+ BULLETIN_DEPLOY_HOST_APP=<your-app-name>
284
+ ```
285
+
286
+ What happens:
287
+
288
+ - `BULLETIN_DEPLOY_USE_AMBIENT_SENTRY=1` makes `initTelemetry()` skip its own `Sentry.init()` call. bulletin-deploy reuses the global Sentry client your app already configured. All deploy spans, breadcrumbs, `captureWarning`/`captureMessage` calls route to your DSN.
289
+ - `BULLETIN_DEPLOY_HOST_APP=<name>` attaches `deploy.host_app: <name>` to every deploy span **and** as a Sentry scope tag, so downstream events in the same process carry it too. Use it to facet dashboards by host.
290
+
291
+ **Requirements:**
292
+
293
+ - Your app must call its own `Sentry.init()` **before** importing or spawning bulletin-deploy; otherwise there is no ambient client to reuse and telemetry is effectively off.
294
+ - Your Sentry project should live in the same Sentry organisation as `bulletin-deploy` (`o4511059872841728.ingest.de.sentry.io`) if you want our cross-project dashboards to aggregate your traffic. Different org = different world; no cross-aggregation is possible.
295
+ - If your consumer app is maintained by Parity, we can add its name to the `PARITY_HOST_APPS` allowlist in `src/telemetry.ts` so end-user installs of the compiled binary qualify for the same diagnostics as our internal CI. Today: `playground-cli`.
296
+
297
+ **Gotchas:**
298
+
299
+ - Quotas are per-project. A traffic spike in your project will eat your quota and can drop bulletin-deploy spans routing through it without any signal in our dashboards.
300
+ - Issue-feed fingerprints don't dedupe across projects: the same error in your project and ours surfaces as two separate Sentry issues.
301
+ - `@sentry/node` major version must be compatible with ours (currently v8.x). Skew risks runtime errors on the first span call.
302
+
277
303
  ### Tagging test and benchmark runs
278
304
 
279
305
  Real-user deploys and automated test/benchmark deploys share the same telemetry pipeline. Use `--tag` (or the `DEPLOY_TAG` env var) to label non-production runs so Sentry dashboards can filter them out:
@@ -2,9 +2,10 @@
2
2
 
3
3
  import { deploy, DEFAULT_BULLETIN_RPC, DEFAULT_POOL_SIZE, NonRetryableError, EXIT_CODE_NO_RETRY } from "../dist/deploy.js";
4
4
  import { bootstrapPool } from "../dist/pool.js";
5
- import { VERSION } from "../dist/telemetry.js";
5
+ import { VERSION, setDeployAttribute, captureWarning, closeTelemetry, setRunStateActive, markRelaunchOomHintShown } from "../dist/telemetry.js";
6
6
  import { handleFailedDeploy, preReleaseWarning } from "../dist/version-check.js";
7
7
  import { setDeployContext, installLogCapture, buildCliFlagsSummary } from "../dist/bug-report.js";
8
+ import { loadRunState, writeRunState, shouldSkipStaleWarning, shouldShowOomHint, probablyOomRssMb } from "../dist/run-state.js";
8
9
  import * as fs from "fs";
9
10
 
10
11
  // Install early so anything printed during flag parsing / preflight is
@@ -60,6 +61,92 @@ Options:
60
61
  const rcWarning = preReleaseWarning(VERSION);
61
62
  if (rcWarning) console.error(rcWarning);
62
63
 
64
+ // ── Crash capture (issue #154) ───────────────────────────────────
65
+ // Only wire crash capture for actual deploy / bootstrap runs — skip for
66
+ // --help / --version (which exit above) and --bootstrap (one-off ops op,
67
+ // no background work worth hinting OOM for).
68
+ if (!flags.help && !flags.version && !flags.bootstrap) {
69
+ // Sanitised argv — positional args + presence-only flag summary. Never
70
+ // puts a mnemonic/password/RPC/derivation-path on disk, even if the user
71
+ // passes one on the command line.
72
+ const sanitizedArgv = [
73
+ ...positional,
74
+ ...buildCliFlagsSummary(flags).split(" ").filter(Boolean),
75
+ ];
76
+
77
+ // Check if the previous run left a stale "running" or "crashed" marker
78
+ // (i.e. this process is a relaunch after a SIGKILL/OOM). Print a hint
79
+ // BEFORE resetting state so the user sees what happened.
80
+ const prev = loadRunState();
81
+ if (prev && (prev.status === "running" || prev.status === "crashed") && !shouldSkipStaleWarning(prev)) {
82
+ if (shouldShowOomHint(prev)) {
83
+ const peak = prev.lastPeakRssMb ?? "unknown";
84
+ const stage = prev.lastStage ?? "unknown";
85
+ const threshold = probablyOomRssMb();
86
+ console.error("");
87
+ console.error(` Warning: previous deploy did not exit cleanly (peak RSS ${peak} MB at stage "${stage}", threshold ${threshold} MB).`);
88
+ console.error(` This looks like an out-of-memory kill. Retry with a larger heap:`);
89
+ console.error(` NODE_OPTIONS='--max-old-space-size=8192' bulletin-deploy ...`);
90
+ console.error("");
91
+ markRelaunchOomHintShown();
92
+ } else if (prev.status === "crashed" && prev.reason) {
93
+ console.error(` Previous deploy exited via ${prev.reason}. Continuing.`);
94
+ } else if (prev.status === "running") {
95
+ console.error(` Previous deploy did not exit cleanly. Continuing.`);
96
+ }
97
+ }
98
+
99
+ // Reset the state file to reflect THIS run. Any previous status (crashed,
100
+ // succeeded, etc.) is overwritten — we already surfaced the warning above.
101
+ writeRunState({
102
+ status: "running",
103
+ pid: process.pid,
104
+ startedAt: Date.now(),
105
+ endedAt: undefined,
106
+ toolVersion: VERSION,
107
+ argv: sanitizedArgv,
108
+ lastPeakRssMb: null,
109
+ lastStage: null,
110
+ reason: undefined,
111
+ });
112
+ setRunStateActive(true);
113
+
114
+ // ── Signal / uncaught handlers ──────────────────────────────
115
+ // Guard against re-entry: if a handler itself throws (e.g. Sentry close
116
+ // rejects), unhandledRejection must not recursively call finalize.
117
+ let finalizing = false;
118
+ const finalize = async (reason, exitCode) => {
119
+ if (finalizing) return;
120
+ finalizing = true;
121
+ try {
122
+ setDeployAttribute("deploy.killed", reason);
123
+ setDeployAttribute("deploy.sad", "true");
124
+ captureWarning(`deploy process terminated: ${reason}`);
125
+ } catch { /* telemetry best-effort */ }
126
+ try { await closeTelemetry(1000); } catch { /* ignore */ }
127
+ try {
128
+ writeRunState({ status: "crashed", endedAt: Date.now(), reason });
129
+ } catch { /* fs best-effort */ }
130
+ process.exit(exitCode);
131
+ };
132
+
133
+ process.on("uncaughtException", (e) => {
134
+ try { setDeployAttribute("deploy.error", (e?.message ?? String(e)).slice(0, 200)); } catch {}
135
+ finalize("uncaught", 2);
136
+ });
137
+ process.on("unhandledRejection", (e) => {
138
+ const msg = e instanceof Error ? e.message : String(e);
139
+ try { setDeployAttribute("deploy.error", msg.slice(0, 200)); } catch {}
140
+ finalize("unhandled", 2);
141
+ });
142
+ // POSIX exit codes: 128 + signal number. Matches shell conventions so
143
+ // callers (e.g. consumer CI with EXIT_CODE_NO_RETRY=75) treat them as
144
+ // retryable.
145
+ process.on("SIGINT", () => finalize("SIGINT", 130));
146
+ process.on("SIGTERM", () => finalize("SIGTERM", 143));
147
+ process.on("SIGHUP", () => finalize("SIGHUP", 129));
148
+ }
149
+
63
150
  try {
64
151
  if (flags.bootstrap) {
65
152
  const rpc = flags.rpc ?? process.env.BULLETIN_RPC ?? DEFAULT_BULLETIN_RPC;
@@ -112,10 +199,16 @@ try {
112
199
  console.log(`CID: ${result.cid}`);
113
200
  console.log(`Domain: ${result.domainName}`);
114
201
  }
202
+ if (!flags.help && !flags.version && !flags.bootstrap) {
203
+ try { writeRunState({ status: "succeeded", endedAt: Date.now() }); } catch {}
204
+ }
115
205
  process.exit(0);
116
206
  } catch (error) {
117
207
  const noRetry = error instanceof NonRetryableError;
118
208
  console.error(`Deployment failed${noRetry ? " (not retryable)" : ""}:`, error.message);
119
209
  await handleFailedDeploy(error);
210
+ if (!flags.help && !flags.version && !flags.bootstrap) {
211
+ try { writeRunState({ status: "failed", endedAt: Date.now(), reason: (error?.message ?? String(error)).slice(0, 200) }); } catch {}
212
+ }
120
213
  process.exit(noRetry ? EXIT_CODE_NO_RETRY : 1);
121
214
  }
@@ -9,9 +9,10 @@ import {
9
9
  offerBugReport,
10
10
  scrubSecrets,
11
11
  setDeployContext
12
- } from "./chunk-4EQERQRG.js";
13
- import "./chunk-MDYSENTW.js";
14
- import "./chunk-BYIVK52G.js";
12
+ } from "./chunk-YYULF2JX.js";
13
+ import "./chunk-CQ753LDA.js";
14
+ import "./chunk-DHQ3JGF4.js";
15
+ import "./chunk-UJP2PZGU.js";
15
16
  import "./chunk-QGM4M3NI.js";
16
17
  export {
17
18
  buildCliFlagsSummary,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-BYIVK52G.js";
3
+ } from "./chunk-DHQ3JGF4.js";
4
4
 
5
5
  // src/version-check.ts
6
6
  import { execSync, execFileSync } from "child_process";
@@ -1,3 +1,8 @@
1
+ import {
2
+ package_default,
3
+ writeRunState
4
+ } from "./chunk-UJP2PZGU.js";
5
+
1
6
  // src/memory-report.ts
2
7
  import * as fs2 from "fs";
3
8
  import * as os from "os";
@@ -9,77 +14,10 @@ import { execSync } from "child_process";
9
14
  import { createHash } from "crypto";
10
15
  import * as fs from "fs";
11
16
  import * as path from "path";
12
-
13
- // package.json
14
- var package_default = {
15
- name: "bulletin-deploy",
16
- version: "0.7.3",
17
- private: false,
18
- repository: {
19
- type: "git",
20
- url: "https://github.com/paritytech/bulletin-deploy.git"
21
- },
22
- publishConfig: {
23
- registry: "https://registry.npmjs.org",
24
- access: "public"
25
- },
26
- type: "module",
27
- main: "./dist/index.js",
28
- types: "./dist/index.d.ts",
29
- bin: {
30
- "bulletin-deploy": "./bin/bulletin-deploy"
31
- },
32
- exports: {
33
- ".": {
34
- types: "./dist/index.d.ts",
35
- import: "./dist/index.js"
36
- }
37
- },
38
- files: [
39
- "dist",
40
- "bin"
41
- ],
42
- scripts: {
43
- build: "tsup src/index.ts src/deploy.ts src/dotns.ts src/pool.ts src/telemetry.ts src/memory-report.ts src/merkle.ts src/gh-pages-mirror.ts src/version-check.ts src/bug-report.ts --format esm --dts --clean --target node22",
44
- prepare: "npm run build",
45
- test: "npm run build && node --test test/test.js test/pool.test.js test/helpers/e2e-helpers.test.js",
46
- "test:e2e": "npm run build && node --test test/e2e.test.js",
47
- "test:e2e:smoke": "bash scripts/e2e-pass.sh smoke",
48
- "test:e2e:pr": "bash scripts/e2e-pass.sh pr",
49
- "test:e2e:nightly": "bash scripts/e2e-pass.sh nightly",
50
- benchmark: "npm run build && node benchmark.js"
51
- },
52
- dependencies: {
53
- "@ipld/car": "^5.4.3",
54
- "@ipld/dag-pb": "^4.1.3",
55
- "@noble/hashes": "^1.7.2",
56
- "@polkadot-api/substrate-bindings": "^0.16.5",
57
- "@polkadot-labs/hdkd": "^0.0.25",
58
- "@polkadot-labs/hdkd-helpers": "^0.0.26",
59
- "@polkadot/keyring": "^13.0.0",
60
- "@polkadot/util-crypto": "^13.0.0",
61
- "@sentry/node": "^9.14.0",
62
- "ipfs-unixfs": "^11.2.0",
63
- "ipfs-unixfs-importer": "^16.1.4",
64
- multiformats: "^13.4.1",
65
- "polkadot-api": "^1.23.1",
66
- viem: "^2.30.5"
67
- },
68
- devDependencies: {
69
- "@types/node": "^22.0.0",
70
- tsup: "^8.5.0",
71
- typescript: "^5.9.3"
72
- },
73
- minimumVersion: "0.5.6",
74
- engines: {
75
- node: ">=22"
76
- }
77
- };
78
-
79
- // src/telemetry.ts
80
17
  var VERSION = package_default.version;
81
18
  var DEFAULT_DSN = "https://e021c025d79c4c3ade2862a11f13c40b@o4511059872841728.ingest.de.sentry.io/4511093597405264";
82
19
  var INTERNAL_ORG_RE = /^(paritytech|w3f|polkadot-fellows)\//i;
20
+ var PARITY_HOST_APPS = /* @__PURE__ */ new Set(["playground-cli"]);
83
21
  function extractRepoSlug(url) {
84
22
  return url.replace(/.*github\.com[:/]/, "").replace(/\.git$/, "");
85
23
  }
@@ -94,13 +32,15 @@ function isInternalContextFromSignals(signals) {
94
32
  if (INTERNAL_ORG_RE.test(signals.githubRepository ?? "")) return true;
95
33
  if (signals.runnerName?.startsWith("parity-")) return true;
96
34
  if (signals.gitRemote && INTERNAL_ORG_RE.test(signals.gitRemote)) return true;
35
+ if (signals.hostApp && PARITY_HOST_APPS.has(signals.hostApp)) return true;
97
36
  return false;
98
37
  }
99
38
  function isInternalContext() {
100
39
  return isInternalContextFromSignals({
101
40
  githubRepository: process.env.GITHUB_REPOSITORY,
102
41
  runnerName: process.env.RUNNER_NAME,
103
- gitRemote: tryGitRemote()
42
+ gitRemote: tryGitRemote(),
43
+ hostApp: process.env.BULLETIN_DEPLOY_HOST_APP
104
44
  });
105
45
  }
106
46
  var OPT_OUT = process.env.BULLETIN_DEPLOY_TELEMETRY === "0";
@@ -155,8 +95,26 @@ if (!DISABLED) {
155
95
  } catch {
156
96
  }
157
97
  }
98
+ var runStateActive = false;
99
+ var relaunchOomHintShown = false;
100
+ function setRunStateActive(v) {
101
+ runStateActive = v;
102
+ }
103
+ function markRelaunchOomHintShown() {
104
+ relaunchOomHintShown = true;
105
+ }
106
+ async function closeTelemetry(timeoutMs) {
107
+ if (!Sentry) return;
108
+ try {
109
+ await Sentry.close(timeoutMs);
110
+ } catch {
111
+ }
112
+ }
158
113
  function initTelemetry() {
159
114
  if (!Sentry) return;
115
+ if (process.env.BULLETIN_DEPLOY_USE_AMBIENT_SENTRY === "1") {
116
+ return;
117
+ }
160
118
  Sentry.init({
161
119
  dsn: process.env.SENTRY_DSN || DEFAULT_DSN,
162
120
  release: `${package_default.name}@${VERSION}`,
@@ -189,6 +147,12 @@ function initTelemetry() {
189
147
  return event;
190
148
  }
191
149
  });
150
+ Sentry.setTag("bulletin-deploy.version", VERSION);
151
+ Sentry.setContext("bulletin-deploy", {
152
+ version: VERSION,
153
+ release: `${package_default.name}@${VERSION}`,
154
+ node: process.version
155
+ });
192
156
  }
193
157
  function tryPackageJsonRepo() {
194
158
  try {
@@ -220,7 +184,8 @@ function resolveRunnerType() {
220
184
  return "github-hosted";
221
185
  }
222
186
  function getDeployAttributes(domain) {
223
- return {
187
+ const hostApp = process.env.BULLETIN_DEPLOY_HOST_APP;
188
+ const attrs = {
224
189
  "deploy.repo": sanitizeRepo(resolveRepo(domain)),
225
190
  "deploy.branch": sanitizeBranch(process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || tryGitBranch()),
226
191
  "deploy.source": process.env.CI ? "ci" : "local",
@@ -230,8 +195,18 @@ function getDeployAttributes(domain) {
230
195
  "deploy.runner_type": resolveRunnerType(),
231
196
  // Seed "false" so successful spans form the %SAD denominator; the catch block and
232
197
  // captureWarning flip it to "true" on friction.
233
- "deploy.sad": "false"
198
+ "deploy.sad": "false",
199
+ // Same ratio-denominator reasoning as deploy.sad above, but for the
200
+ // %EXPECTED-refusal metric: catch block flips to "true" when the error
201
+ // matches isExpectedError (user-facing product rule, not tool friction).
202
+ "deploy.expected": "false",
203
+ // Seed "false" so every span carries the attribute (boolean-both-values rule).
204
+ // Flipped to "true" by getWsProvider's onStatusChanged when papi connects to a
205
+ // non-primary endpoint, and flushed again in deploy()'s finally block.
206
+ "deploy.rpc.failed_over": "false"
234
207
  };
208
+ if (hostApp) attrs["deploy.host_app"] = hostApp;
209
+ return attrs;
235
210
  }
236
211
  function isExpectedError(msg) {
237
212
  return /personhood|owned by|owner mismatch|reserved for original|invalid domain label|not authorized for bulletin|insufficient balance|quota exhausted|insufficient .* authorization/i.test(msg);
@@ -281,6 +256,12 @@ function sampleMemory(stage) {
281
256
  deployRootSpan.setAttribute("deploy.mem.peak_array_buffers_mb", String(toMb(memoryPeak.arrayBuffers)));
282
257
  }
283
258
  }
259
+ if (runStateActive && memoryPeak) {
260
+ try {
261
+ writeRunState({ lastPeakRssMb: toMb(memoryPeak.rss), lastStage: stage });
262
+ } catch {
263
+ }
264
+ }
284
265
  }
285
266
  async function withDeploySpan(domain, fn) {
286
267
  if (!Sentry) return fn();
@@ -293,22 +274,30 @@ async function withDeploySpan(domain, fn) {
293
274
  try {
294
275
  return await Sentry.startSpan({ op: "deploy", name: `deploy ${domain}`, attributes: attrs }, async (span) => {
295
276
  deployRootSpan = span;
296
- Sentry.setTags({
277
+ span.setAttribute("deploy.tool_version", VERSION);
278
+ if (relaunchOomHintShown) {
279
+ span.setAttribute("deploy.relaunch.oom_hint_shown", "true");
280
+ }
281
+ const tagsToSet = {
297
282
  "deploy.repo": attrs["deploy.repo"],
298
283
  "deploy.branch": attrs["deploy.branch"],
299
284
  "deploy.domain": domain,
300
285
  "deploy.source": attrs["deploy.source"],
301
286
  "deploy.tool_version": VERSION,
302
- "deploy.runner_type": resolveRunnerType()
303
- });
287
+ "deploy.runner_type": resolveRunnerType(),
288
+ "deploy.host_app": attrs["deploy.host_app"] ?? ""
289
+ };
290
+ if (!tagsToSet["deploy.host_app"]) delete tagsToSet["deploy.host_app"];
291
+ Sentry.setTags(tagsToSet);
304
292
  try {
305
293
  return await fn();
306
294
  } catch (error) {
307
295
  const msg = error.message;
308
296
  span.setAttribute("deploy.status", "error");
309
297
  span.setAttribute("deploy.error", msg.slice(0, 200));
310
- span.setAttribute("deploy.sad", "true");
311
298
  const isExpected = isExpectedError(msg);
299
+ span.setAttribute("deploy.expected", isExpected ? "true" : "false");
300
+ span.setAttribute("deploy.sad", isExpected ? "false" : "true");
312
301
  if (!isExpected) {
313
302
  span.setStatus({ code: 2, message: "internal_error" });
314
303
  }
@@ -524,6 +513,9 @@ export {
524
513
  truncateAddress,
525
514
  sanitizeBranch,
526
515
  sanitizeRepo,
516
+ setRunStateActive,
517
+ markRelaunchOomHintShown,
518
+ closeTelemetry,
527
519
  initTelemetry,
528
520
  resolveRepo,
529
521
  resolveRunner,