@scrymore/scry-deployer 0.3.0 → 0.3.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.
package/README.md CHANGED
@@ -835,6 +835,31 @@ Then update the `if` condition in the notification steps:
835
835
 
836
836
  ---
837
837
 
838
+ ## šŸ“Š Error Reporting
839
+
840
+ When a deploy fails, this CLI reports the error to Scry so we can fix it. It runs on
841
+ your machine, so it is worth being precise about what leaves it.
842
+
843
+ **Sent:** the error and its stack trace, the CLI and Node versions, the platform, and
844
+ the project id, deploy version, branch and whether analysis was enabled.
845
+
846
+ **Not sent:** your API key, presigned upload URLs and their signatures, absolute file
847
+ paths, your hostname or username, your component names, and your source code. Stack
848
+ frames are reduced to file basenames, and anything resembling a credential is redacted
849
+ before the report is sent — including the signed URLs that upload errors would
850
+ otherwise quote in full.
851
+
852
+ Nothing is reported on a successful run.
853
+
854
+ **To opt out**, set either variable:
855
+
856
+ ```bash
857
+ export SCRY_TELEMETRY=0 # Scry-specific
858
+ export DO_NOT_TRACK=1 # respected across many CLI tools
859
+ ```
860
+
861
+ Both are honoured everywhere, including CI.
862
+
838
863
  ## šŸ”§ Troubleshooting the Init Command
839
864
 
840
865
  ### Command fails with "Not a git repository"
package/bin/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const Sentry = require('@sentry/node');
3
+ const { initTelemetry, captureCliError, flushTelemetry } = require('../lib/telemetry.js');
4
4
  const yargs = require('yargs/yargs');
5
5
  const { hideBin } = require('yargs/helpers');
6
6
  const fs = require('fs');
@@ -166,21 +166,12 @@ async function handleError(error, argv) {
166
166
  const logger = createLogger(argv || {});
167
167
  logger.error(`\nāŒ Error: ${error.message}`);
168
168
 
169
- // Capture error in Sentry with additional context
170
- Sentry.withScope((scope) => {
171
- if (argv) {
172
- scope.setTags({
173
- project: argv.project,
174
- version: argv.version,
175
- command: argv._ ? argv._[0] : 'unknown',
176
- });
177
- scope.setExtra('argv', argv);
178
- }
179
- Sentry.captureException(error);
180
- });
181
-
169
+ // Report with an allowlisted subset of argv. Sending argv wholesale shipped
170
+ // the customer's --api-key to Sentry on every error.
171
+ captureCliError(error, argv);
172
+
182
173
  // Ensure the event is sent before the process exits
183
- await Sentry.close(2000);
174
+ await flushTelemetry(2000);
184
175
 
185
176
  if (error instanceof ApiError) {
186
177
  if (error.statusCode === 401) {
@@ -198,12 +189,9 @@ async function handleError(error, argv) {
198
189
  }
199
190
 
200
191
  async function main() {
201
- // Initialize Sentry
202
- Sentry.init({
203
- dsn: "https://c66ce229a1db2289f145eebd02436d9c@o4507889391828992.ingest.us.sentry.io/4510699330732032", // Fallback to hardcoded DSN for user reporting
204
- tracesSampleRate: 1.0,
205
- environment: process.env.NODE_ENV || 'production',
206
- });
192
+ // Error reporting. Opt out with SCRY_TELEMETRY=0 or DO_NOT_TRACK=1.
193
+ // Configuration and scrubbing live in lib/telemetry.js.
194
+ initTelemetry();
207
195
 
208
196
  try {
209
197
  const args = await yargs(hideBin(process.argv))
package/lib/apiClient.js CHANGED
@@ -16,10 +16,83 @@ const logger = createLogger({ verbose: isVerbose });
16
16
  const COVERAGE_UPLOAD_DELAY_MS = 5000;
17
17
  const COVERAGE_RETRY_DELAY_MS = 60000;
18
18
 
19
+ // The upload is the last step of a deploy, so a blip here discards several
20
+ // minutes of completed screenshot capture. Retrying is far cheaper than
21
+ // redoing that work. See ISSUES.md #19.
22
+ const UPLOAD_MAX_ATTEMPTS = 4;
23
+ const UPLOAD_BACKOFF_BASE_MS = 2000;
24
+
25
+ // Network-level failures that a later attempt can plausibly survive. Flapping
26
+ // DNS reports EAI_AGAIN (and, mid-flap, ENOTFOUND) rather than a clean refusal.
27
+ const TRANSIENT_CODES = new Set([
28
+ 'EAI_AGAIN',
29
+ 'ENOTFOUND',
30
+ 'ECONNRESET',
31
+ 'ECONNREFUSED',
32
+ 'ETIMEDOUT',
33
+ 'ECONNABORTED',
34
+ 'EPIPE',
35
+ 'EHOSTUNREACH',
36
+ 'ENETUNREACH',
37
+ 'ERR_NETWORK',
38
+ ]);
39
+
19
40
  function sleep(ms) {
20
41
  return new Promise((resolve) => setTimeout(resolve, ms));
21
42
  }
22
43
 
44
+ /**
45
+ * Whether a failed upload attempt is worth repeating.
46
+ *
47
+ * A 4xx other than 429 means the request itself is wrong — retrying it just
48
+ * burns time and produces the identical failure.
49
+ *
50
+ * @param {Error & {code?: string, response?: {status?: number}}} error
51
+ * @returns {boolean}
52
+ */
53
+ function isTransientUploadError(error) {
54
+ const status = error?.response?.status;
55
+ if (typeof status === 'number') {
56
+ return status === 429 || status >= 500;
57
+ }
58
+ return TRANSIENT_CODES.has(error?.code);
59
+ }
60
+
61
+ /**
62
+ * Run an upload attempt, repeating it while the failure looks transient.
63
+ *
64
+ * @template T
65
+ * @param {(attempt: number) => Promise<T>} attemptFn
66
+ * @param {string} label Shown in logs so a retried upload does not look like a hang.
67
+ * @returns {Promise<T>}
68
+ */
69
+ async function withUploadRetry(attemptFn, label) {
70
+ let lastError;
71
+
72
+ for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt++) {
73
+ try {
74
+ return await attemptFn(attempt);
75
+ } catch (error) {
76
+ lastError = error;
77
+
78
+ if (!isTransientUploadError(error) || attempt === UPLOAD_MAX_ATTEMPTS) {
79
+ throw error;
80
+ }
81
+
82
+ const backoff = UPLOAD_BACKOFF_BASE_MS * Math.pow(2, attempt - 1);
83
+ const reason = error.code || `HTTP ${error?.response?.status}`;
84
+ // Deliberately at info level: a silent retry is indistinguishable from a
85
+ // hung deploy, which is its own reported failure mode.
86
+ logger.info(
87
+ `${label} attempt ${attempt}/${UPLOAD_MAX_ATTEMPTS} failed (${reason}); retrying in ${backoff / 1000}s...`
88
+ );
89
+ await sleep(backoff);
90
+ }
91
+ }
92
+
93
+ throw lastError;
94
+ }
95
+
23
96
  /**
24
97
  * Creates a pre-configured axios instance for making API calls.
25
98
  * @param {string} apiUrl The base URL of the API.
@@ -205,9 +278,14 @@ async function uploadFileDirectly(apiClient, { project, version }, filePath, fil
205
278
  const contentType = file.contentType || 'application/zip';
206
279
 
207
280
  try {
208
- const presigned = await requestPresignedUrl(apiClient, { project, version }, { fileName, contentType });
209
- const upload = await putToPresignedUrl(presigned.url, fileBuffer, contentType);
210
- return { success: true, url: presigned.url, status: upload.status, visibility: presigned.visibility };
281
+ // The presigned URL is signed at request time, so a retry after a long
282
+ // backoff must re-request it — reusing a stale one fails with a confusing
283
+ // signature error instead of the real network cause.
284
+ return await withUploadRetry(async () => {
285
+ const presigned = await requestPresignedUrl(apiClient, { project, version }, { fileName, contentType });
286
+ const upload = await putToPresignedUrl(presigned.url, fileBuffer, contentType);
287
+ return { success: true, url: presigned.url, status: upload.status, visibility: presigned.visibility };
288
+ }, `Upload of ${fileName}`);
211
289
  } catch (error) {
212
290
  logger.debug(`Upload failed. Error type: ${error.constructor.name}, Message: ${error.message}`);
213
291
  const details = getAxiosErrorDetails(error, apiClient.defaults.baseURL);
@@ -374,4 +452,7 @@ module.exports = {
374
452
  uploadBuild,
375
453
  requestPresignedUrl,
376
454
  putToPresignedUrl,
455
+ isTransientUploadError,
456
+ withUploadRetry,
457
+ UPLOAD_MAX_ATTEMPTS,
377
458
  };
@@ -0,0 +1,140 @@
1
+ const Sentry = require('@sentry/node');
2
+
3
+ /**
4
+ * Error reporting for a CLI that runs on other people's machines.
5
+ *
6
+ * This is not a server. Everything it sends left a customer's laptop or CI
7
+ * runner, so the default has to be "send the minimum that makes a crash
8
+ * diagnosable", not "send the context and sort it out later".
9
+ *
10
+ * Three things were being sent that should not have been:
11
+ *
12
+ * 1. `scope.setExtra('argv', argv)` shipped the whole parsed argv — which
13
+ * contains `--api-key` under both `apiKey` and `api-key`. Every customer
14
+ * error carried their project credential to a third party.
15
+ * 2. Upload failures embed the presigned URL in the message, query string and
16
+ * all: `...storybook.zip?X-Amz-Signature=645e57...`. That signature is a
17
+ * time-limited write credential for the bucket.
18
+ * 3. Absolute paths (`/home/alice/work/app`) leak usernames and, often,
19
+ * unreleased product names.
20
+ *
21
+ * None of that is needed to know that an upload failed with EAI_AGAIN.
22
+ */
23
+
24
+ const DSN =
25
+ 'https://c66ce229a1db2289f145eebd02436d9c@o4507889391828992.ingest.us.sentry.io/4510699330732032';
26
+
27
+ /**
28
+ * argv fields safe to attach to an error.
29
+ *
30
+ * An allowlist, not a denylist: a new option should be invisible to telemetry
31
+ * until someone deliberately adds it here. The reverse — remembering to exclude
32
+ * each new secret — is the failure mode that put an API key in Sentry.
33
+ */
34
+ const SAFE_ARGV_FIELDS = ['project', 'deployVersion', 'withAnalysis', 'verbose', 'branch'];
35
+
36
+ /** Anything that looks like a credential, wherever it appears in a string. */
37
+ const SECRET_PATTERNS = [
38
+ // Presigned URL query strings. Keep the path so the failing operation is
39
+ // still identifiable; drop the signature and everything with it.
40
+ [/(https?:\/\/[^\s?]+)\?[^\s]*/g, '$1?<redacted>'],
41
+ [/scry_proj_[A-Za-z0-9_\-]+/g, 'scry_proj_<redacted>'],
42
+ [/(X-Amz-Signature=)[^&\s]+/gi, '$1<redacted>'],
43
+ [/(Bearer\s+)[A-Za-z0-9._\-]+/gi, '$1<redacted>'],
44
+ ];
45
+
46
+ function scrub(value) {
47
+ if (typeof value !== 'string') return value;
48
+ return SECRET_PATTERNS.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), value);
49
+ }
50
+
51
+ /**
52
+ * Whether the user has asked not to be tracked.
53
+ *
54
+ * DO_NOT_TRACK is honoured as well as our own flag — it is the cross-tool
55
+ * convention (consoledonottrack.com), and a developer who has set it globally
56
+ * should not have to discover a Scry-specific variable to be heard.
57
+ */
58
+ function telemetryDisabled() {
59
+ const off = (v) => v === '1' || v === 'true' || v === 'yes';
60
+ return off(process.env.DO_NOT_TRACK) || process.env.SCRY_TELEMETRY === '0' ||
61
+ process.env.SCRY_TELEMETRY === 'false';
62
+ }
63
+
64
+ /** Only the fields on the allowlist, and scrubbed even then. */
65
+ function sanitizeArgv(argv) {
66
+ if (!argv) return {};
67
+ const safe = {};
68
+ for (const field of SAFE_ARGV_FIELDS) {
69
+ if (argv[field] !== undefined) safe[field] = scrub(argv[field]);
70
+ }
71
+ return safe;
72
+ }
73
+
74
+ function initTelemetry() {
75
+ if (telemetryDisabled()) return false;
76
+
77
+ Sentry.init({
78
+ dsn: DSN,
79
+ environment: process.env.NODE_ENV || 'production',
80
+
81
+ // Errors only. Traces from a CLI describe the customer's build pipeline,
82
+ // which is more than is needed to fix a crash.
83
+ tracesSampleRate: 0,
84
+
85
+ // No usernames, IPs, or machine hostnames.
86
+ sendDefaultPii: false,
87
+ serverName: false,
88
+
89
+ beforeSend(event) {
90
+ if (event.message) event.message = scrub(event.message);
91
+
92
+ for (const entry of event.exception?.values ?? []) {
93
+ if (entry.value) entry.value = scrub(entry.value);
94
+ // Stack frames carry absolute paths from the customer's disk. The
95
+ // filename is what makes a trace useful, so keep the basename only.
96
+ for (const frame of entry.stacktrace?.frames ?? []) {
97
+ if (frame.filename) frame.filename = frame.filename.replace(/^.*[\\/]/, '');
98
+ delete frame.abs_path;
99
+ }
100
+ }
101
+
102
+ // Belt and braces: whatever else ends up in extra, scrub its strings.
103
+ if (event.extra) {
104
+ for (const [k, v] of Object.entries(event.extra)) event.extra[k] = scrub(v);
105
+ }
106
+
107
+ return event;
108
+ },
109
+ });
110
+
111
+ return true;
112
+ }
113
+
114
+ /** Report an error with only the context that is safe to leave the machine. */
115
+ function captureCliError(error, argv) {
116
+ if (telemetryDisabled()) return;
117
+
118
+ Sentry.withScope((scope) => {
119
+ const safe = sanitizeArgv(argv);
120
+ if (safe.project) scope.setTag('project', safe.project);
121
+ if (argv && argv._) scope.setTag('command', argv._[0] || 'deploy');
122
+ scope.setExtra('options', safe);
123
+ Sentry.captureException(error);
124
+ });
125
+ }
126
+
127
+ async function flushTelemetry(ms = 2000) {
128
+ if (telemetryDisabled()) return;
129
+ await Sentry.close(ms);
130
+ }
131
+
132
+ module.exports = {
133
+ initTelemetry,
134
+ captureCliError,
135
+ flushTelemetry,
136
+ telemetryDisabled,
137
+ sanitizeArgv,
138
+ scrub,
139
+ SAFE_ARGV_FIELDS,
140
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrymore/scry-deployer",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "A CLI to automate the deployment of Storybook static builds.",
5
5
  "main": "index.js",
6
6
  "bin": {