neon 8.0.4 → 8.0.5

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/dist/analytics.js CHANGED
@@ -75,8 +75,12 @@ const telemetryCredential = (authContext, apiKey, defaultCredentialsPath) => {
75
75
  };
76
76
  let client;
77
77
  let clientInitialized = false;
78
- let userId = "";
79
78
  let errorEventContext;
79
+ /** Lets tests send to a local collector; the CLI itself always uses track.neon.tech. */
80
+ const useAnalyticsClientForTests = (analytics) => {
81
+ client = analytics;
82
+ clientInitialized = true;
83
+ };
80
84
  /**
81
85
  * Phase 1: Run before validation so the Segment client exists if any
82
86
  * middleware (e.g. auth) fails. Enables sendError() in the fail handler.
@@ -95,12 +99,37 @@ const initAnalyticsClientMiddleware = (args) => {
95
99
  client.identify({ userId: ANONYMOUS });
96
100
  };
97
101
  /**
98
- * Phase 2: Run after auth. Resolves user id from credentials,
99
- * identifies the user, and sends CLI Started.
102
+ * The attribution of the current attempt. A 401 retry runs the middleware again and starts a
103
+ * new one; events keep the attempt they were tracked in, even while its lookup is in flight.
104
+ */
105
+ let attribution = { userId: "" };
106
+ /**
107
+ * Every event goes out through this chain so events tracked while the account lookup is in
108
+ * flight are sent after "CLI Started", with the user id the lookup resolves, in call order.
100
109
  */
101
- const analyticsMiddleware = async (args) => {
110
+ let queue = Promise.resolve();
111
+ const lookups = /* @__PURE__ */ new Set();
112
+ const enqueue = (send) => {
113
+ const analytics = client;
114
+ if (!analytics) return;
115
+ const attempt = attribution;
116
+ queue = queue.then(() => {
117
+ try {
118
+ send(analytics, attempt);
119
+ } catch (err) {
120
+ log.debug("Could not queue a CLI analytics event: %s", err);
121
+ }
122
+ });
123
+ };
124
+ /**
125
+ * Phase 2: Run after auth. Starts resolving the user id from credentials and returns; the
126
+ * command does not wait for it. Identify and CLI Started go out once it settles.
127
+ */
128
+ const analyticsMiddleware = (args) => {
102
129
  if (!client || !args.analytics) return;
103
130
  if (isCurrentBranchProbe(args)) return;
131
+ const attempt = { userId: attribution.userId };
132
+ attribution = attempt;
104
133
  const { apiKey: keyToQuery, credentialsPath: fileToRead } = telemetryCredential(getAuthContext(), args.apiKey, credentialsPath(args.configDir));
105
134
  if (fileToRead !== void 0) try {
106
135
  const listing = storeFor(args.configDir).inspect({
@@ -108,7 +137,7 @@ const analyticsMiddleware = async (args) => {
108
137
  storage: "file",
109
138
  path: fileToRead
110
139
  });
111
- if (typeof listing.credentials?.user_id === "string") userId = listing.credentials.user_id;
140
+ if (typeof listing.credentials?.user_id === "string") attempt.userId = listing.credentials.user_id;
112
141
  else log.debug("No usable credentials at %s", fileToRead);
113
142
  } catch (err) {
114
143
  log.debug("Could not read %s: %s", fileToRead, err);
@@ -118,50 +147,76 @@ const analyticsMiddleware = async (args) => {
118
147
  profile: getAuthContext()?.profile ?? "DEFAULT",
119
148
  storage: "keyring"
120
149
  });
121
- if (typeof listing.credentials?.user_id === "string") userId = listing.credentials.user_id;
150
+ if (typeof listing.credentials?.user_id === "string") attempt.userId = listing.credentials.user_id;
122
151
  } catch (err) {
123
152
  log.debug("Could not read the OS keyring item: %s", err);
124
153
  }
125
- try {
126
- if (keyToQuery) {
127
- const apiClient = getApiClient({
128
- apiKey: keyToQuery,
129
- apiHost: args.apiHost
130
- });
131
- const authDetails = (await apiClient.getAuthDetails()).data;
132
- args.accountId = authDetails.account_id;
133
- args.authMethod = authDetails.auth_method;
134
- args.authData = authDetails.auth_data;
135
- if (!userId && authDetails.auth_method !== "api_key_org") userId = (await apiClient?.getCurrentUserInfo?.())?.data?.id;
136
- } else {
137
- const { accountId, authMethod } = storedCredentialAttribution(userId);
138
- args.accountId = accountId;
139
- args.authMethod = authMethod;
154
+ const startedProperties = getAnalyticsEventProperties(args);
155
+ const controller = new AbortController();
156
+ lookups.add(controller);
157
+ const lookup = (async () => {
158
+ try {
159
+ if (keyToQuery) {
160
+ const apiClient = getApiClient({
161
+ apiKey: keyToQuery,
162
+ apiHost: args.apiHost
163
+ });
164
+ const { data: authDetails } = await apiClient.getAuthDetails({ signal: controller.signal });
165
+ attempt.accountId = authDetails.account_id;
166
+ attempt.authMethod = authDetails.auth_method;
167
+ attempt.authData = authDetails.auth_data;
168
+ if (!attempt.userId && authDetails.auth_method !== "api_key_org") {
169
+ const resp = await apiClient.getCurrentUserInfo({ signal: controller.signal });
170
+ attempt.userId = resp.data.id;
171
+ }
172
+ } else Object.assign(attempt, storedCredentialAttribution(attempt.userId));
173
+ } catch (err) {
174
+ log.debug("Failed to get user id from api", err);
175
+ } finally {
176
+ lookups.delete(controller);
140
177
  }
141
- } catch (err) {
142
- log.debug("Failed to get user id from api", err);
143
- }
144
- client.identify({ userId: analyticsUserId(userId) });
145
- client.track({
146
- userId: analyticsUserId(userId),
147
- event: "CLI Started",
148
- properties: getAnalyticsEventProperties(args),
149
- context: { direct: true }
178
+ })();
179
+ queue = queue.then(() => lookup);
180
+ enqueue((analytics, { userId }) => {
181
+ analytics.identify({ userId: analyticsUserId(userId) });
182
+ analytics.track({
183
+ userId: analyticsUserId(userId),
184
+ event: "CLI Started",
185
+ properties: startedProperties,
186
+ context: { direct: true }
187
+ });
150
188
  });
151
189
  };
190
+ /** How long closing waits for an unfinished account lookup before aborting it. */
191
+ const ATTRIBUTION_WAIT_MS = 1e3;
152
192
  let closing;
153
193
  /**
154
- * Close the client and flush queued events. Later calls share the first flush: the psql
155
- * launcher starts one while psql runs, and a second `closeAndFlush` would only make the SDK
156
- * warn about overlapping flushes.
194
+ * Send queued events, then close the client and flush. Later calls share the first close: the
195
+ * psql launcher starts one while psql runs, and a second `closeAndFlush` would only make the SDK
196
+ * warn about overlapping flushes. `timeout` bounds the whole close, lookup wait included.
157
197
  */
158
198
  const closeAnalytics = (opts) => {
159
- if (!client) return Promise.resolve();
199
+ const analytics = client;
200
+ if (!analytics) return Promise.resolve();
160
201
  if (!closing) {
161
- log.debug("Flushing CLI analytics");
162
- closing = client.closeAndFlush(opts).then(() => {
202
+ const started = Date.now();
203
+ closing = (async () => {
204
+ const wait = Math.min(ATTRIBUTION_WAIT_MS, (opts?.timeout ?? Infinity) / 2);
205
+ let timer;
206
+ const timedOut = await Promise.race([queue.then(() => false), new Promise((resolve) => {
207
+ timer = setTimeout(() => resolve(true), wait);
208
+ })]);
209
+ clearTimeout(timer);
210
+ if (timedOut) {
211
+ log.debug("Account lookup unfinished; sending events without it");
212
+ for (const controller of lookups) controller.abort();
213
+ await queue;
214
+ }
215
+ log.debug("Flushing CLI analytics");
216
+ const flush = opts?.timeout === void 0 ? void 0 : { timeout: Math.max(1, opts.timeout - (Date.now() - started)) };
217
+ await analytics.closeAndFlush(flush);
163
218
  log.debug("Flushed CLI analytics");
164
- });
219
+ })();
165
220
  }
166
221
  return closing;
167
222
  };
@@ -188,22 +243,47 @@ const sendError = (err, errCode) => {
188
243
  if (!client) return;
189
244
  const requestId = (isNeonApiError(err) ? err : void 0)?.headers?.["x-neon-ret-request-id"];
190
245
  if (requestId) log.debug("Failed request ID: %s", requestId);
191
- client.track({
192
- event: "CLI Error",
193
- userId: analyticsUserId(userId),
194
- properties: getErrorAnalyticsEventProperties(err, errCode, errorEventContext)
246
+ const properties = getErrorAnalyticsEventProperties(err, errCode, errorEventContext);
247
+ enqueue((analytics, { userId }) => {
248
+ analytics.track({
249
+ event: "CLI Error",
250
+ userId: analyticsUserId(userId),
251
+ properties
252
+ });
195
253
  });
196
254
  log.debug("Sent CLI error event: %s", errCode);
197
255
  };
198
256
  const trackEvent = (event, properties) => {
199
257
  if (!client) return;
200
- client.track({
201
- event,
202
- userId: analyticsUserId(userId),
203
- properties
258
+ enqueue((analytics, { userId }) => {
259
+ analytics.track({
260
+ event,
261
+ userId: analyticsUserId(userId),
262
+ properties
263
+ });
204
264
  });
205
265
  log.debug("Sent CLI event: %s", event);
206
266
  };
267
+ /**
268
+ * Tracks cli_command_success. The account fields come from the lookup, which may still be in
269
+ * flight when the command finishes, so they are read when the event is sent.
270
+ */
271
+ const trackCommandSuccess = (args) => {
272
+ if (!client) return;
273
+ const properties = commandSuccessProperties(args);
274
+ enqueue((analytics, { userId, accountId, authMethod, authData }) => {
275
+ analytics.track({
276
+ event: "cli_command_success",
277
+ userId: analyticsUserId(userId),
278
+ properties: {
279
+ ...properties,
280
+ accountId,
281
+ authMethod,
282
+ authData
283
+ }
284
+ });
285
+ });
286
+ };
207
287
  let commandSuccessExtras = {};
208
288
  const recordCommandSuccessExtras = (patch) => {
209
289
  commandSuccessExtras = {
@@ -242,4 +322,4 @@ const getAnalyticsEventProperties = (args) => ({
242
322
  githubEnvVars: getGithubEnvVars(process.env)
243
323
  });
244
324
  //#endregion
245
- export { analyticsMiddleware, analyticsUserId, closeAnalytics, commandSuccessProperties, getAnalyticsEventProperties, getErrorAnalyticsEventProperties, initAnalyticsClientMiddleware, recordCommandSuccessExtras, recordScaffoldedTemplate, sendError, storedCredentialAttribution, takeCommandSuccessExtras, telemetryCredential, trackEvent };
325
+ export { analyticsMiddleware, analyticsUserId, closeAnalytics, commandSuccessProperties, getAnalyticsEventProperties, getErrorAnalyticsEventProperties, initAnalyticsClientMiddleware, recordCommandSuccessExtras, recordScaffoldedTemplate, sendError, storedCredentialAttribution, takeCommandSuccessExtras, telemetryCredential, trackCommandSuccess, trackEvent, useAnalyticsClientForTests };
package/dist/api.js CHANGED
@@ -333,9 +333,15 @@ const getApiClient = ({ apiKey, apiHost, requestTimeoutMs = REQUEST_TIMEOUT_MS }
333
333
  }
334
334
  return {
335
335
  request,
336
- getCurrentUserInfo: () => call(() => raw.getCurrentUserInfo({ client })),
336
+ getCurrentUserInfo: (options = {}) => call(() => raw.getCurrentUserInfo({
337
+ client,
338
+ signal: options.signal
339
+ })),
337
340
  getCurrentUserOrganizations: () => call(() => raw.getCurrentUserOrganizations({ client })),
338
- getAuthDetails: () => call(() => raw.getAuthDetails({ client })),
341
+ getAuthDetails: (options = {}) => call(() => raw.getAuthDetails({
342
+ client,
343
+ signal: options.signal
344
+ })),
339
345
  getActiveRegions: () => call(() => raw.getActiveRegions({ client })),
340
346
  listApiKeys: () => call(() => raw.listApiKeys({ client })),
341
347
  createApiKey: (body) => call(() => raw.createApiKey({
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { getAuthContext } from "./auth_context.js";
5
5
  import { r as defaultDir } from "./_chunks/paths-DMq0Lt7a.js";
6
6
  import { ensureConfigDir } from "./config.js";
7
7
  import { currentContextFile, enrichFromContext } from "./context.js";
8
- import { analyticsMiddleware, closeAnalytics, commandSuccessProperties, initAnalyticsClientMiddleware, sendError, takeCommandSuccessExtras, trackEvent } from "./analytics.js";
8
+ import { analyticsMiddleware, closeAnalytics, initAnalyticsClientMiddleware, sendError, takeCommandSuccessExtras, trackCommandSuccess } from "./analytics.js";
9
9
  import { NETWORK_ERROR_MESSAGE, isNetworkError, matchErrorCode } from "./errors.js";
10
10
  import { AuthRefreshError, defaultClientID } from "./auth.js";
11
11
  import { getCliName } from "./utils/cli_name.js";
@@ -199,7 +199,7 @@ async function handleError(msg, err, canRetry) {
199
199
  await preloadCommand(builder, hideBin(process.argv));
200
200
  while (attempts < MAX_ATTEMPTS) try {
201
201
  const args = await builder.argv;
202
- trackEvent("cli_command_success", commandSuccessProperties(args));
202
+ trackCommandSuccess(args);
203
203
  if (args._.length === 0 || args.help) {
204
204
  await showHelp(builder);
205
205
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neon",
3
- "version": "8.0.4",
3
+ "version": "8.0.5",
4
4
  "description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
5
5
  "keywords": [
6
6
  "neon",
@@ -64,9 +64,9 @@
64
64
  "yaml": "^2.9.0",
65
65
  "yargs": "17.7.2",
66
66
  "yoctocolors": "^2.1.2",
67
- "@neon/config": "1.8.3",
68
67
  "@neon/config-runtime": "1.7.3",
69
- "@neon/sdk": "7.0.0"
68
+ "@neon/sdk": "7.0.0",
69
+ "@neon/config": "1.8.3"
70
70
  },
71
71
  "optionalDependencies": {
72
72
  "@napi-rs/keyring": "1.3.0",