heroku 11.8.0 → 11.8.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
6
 
7
+ ## [11.8.1](https://github.com/heroku/cli/compare/v11.8.0...v11.8.1) (2026-07-08)
8
+
9
+
10
+ ### Bug Fixes
11
+
12
+ * consolidate pg_stat_statements handling ([#3799](https://github.com/heroku/cli/issues/3799)) ([e36b494](https://github.com/heroku/cli/commit/e36b49472b67f5fdcaf5332764d996fe1994ecfd))
13
+ * keep process type resolvable with heroku run --exit-code ([#3804](https://github.com/heroku/cli/issues/3804)) ([6383b03](https://github.com/heroku/cli/commit/6383b03e21b4bb088bc39f13fafb8a3c680f53a2))
14
+ * migration method selection navigation bug (W-23306091) ([#3802](https://github.com/heroku/cli/issues/3802)) ([8562909](https://github.com/heroku/cli/commit/85629096f63f5e646975d90475ad18e36c8fb53e))
15
+ * restore Sentry release tag from spawned telemetry worker ([#3801](https://github.com/heroku/cli/issues/3801)) ([407a7e0](https://github.com/heroku/cli/commit/407a7e04901844e91b91aeeea70fbdd5085304a5))
16
+
17
+
18
+ ### Code Refactoring
19
+
20
+ * trim exit-code command comment ([7a2cfed](https://github.com/heroku/cli/commit/7a2cfed35c292ccd14a476c88c90949ec1c2c798))
21
+
7
22
  ## [11.8.0](https://github.com/heroku/cli/compare/v11.7.1...v11.8.0) (2026-07-01)
8
23
 
9
24
 
@@ -13,6 +13,7 @@ export default class DataPgMigrate extends BaseCommand {
13
13
  private appName;
14
14
  private classicDatabases;
15
15
  private extendedLevelsInfo;
16
+ private methodProvidedViaFlag;
16
17
  private migrationTargets;
17
18
  private selectedMigrationMethod?;
18
19
  createAddon(...args: Parameters<typeof createAddon>): Promise<Heroku.AddOn>;
@@ -26,6 +26,7 @@ export default class DataPgMigrate extends BaseCommand {
26
26
  appName;
27
27
  classicDatabases = [];
28
28
  extendedLevelsInfo;
29
+ methodProvidedViaFlag = false;
29
30
  migrationTargets = [];
30
31
  selectedMigrationMethod;
31
32
  async createAddon(...args) {
@@ -38,8 +39,10 @@ export default class DataPgMigrate extends BaseCommand {
38
39
  const { flags } = await this.parse(DataPgMigrate);
39
40
  const { app, method } = flags;
40
41
  this.appName = app;
41
- // If --method flag is provided, convert and store
42
+ // If --method flag is provided, convert and store, and record that the method
43
+ // came from the flag so the interactive selection step is skipped throughout.
42
44
  if (method !== undefined) {
45
+ this.methodProvidedViaFlag = true;
43
46
  this.selectedMigrationMethod = method === 'streaming' ? MigrationMethod.CDC : MigrationMethod.FULL_LOAD;
44
47
  }
45
48
  ux.stdout(heredoc `
@@ -146,6 +149,12 @@ export default class DataPgMigrate extends BaseCommand {
146
149
  }
147
150
  }
148
151
  async configureMigration() {
152
+ // When the method wasn't provided via the --method flag, clear any method
153
+ // selected during a previous migration in this session so a stale value can
154
+ // never reach the migration request if the interactive step is ever skipped.
155
+ if (!this.methodProvidedViaFlag) {
156
+ this.selectedMigrationMethod = undefined;
157
+ }
149
158
  let currentStep = '__select_source';
150
159
  let sourceDatabaseId;
151
160
  let targetDatabaseId;
@@ -266,7 +275,7 @@ export default class DataPgMigrate extends BaseCommand {
266
275
  case '__confirm_migration': {
267
276
  const action = await confirmMigration();
268
277
  if (action === '__go_back') {
269
- currentStep = '__select_target';
278
+ currentStep = this.methodProvidedViaFlag ? '__select_target' : '__select_method';
270
279
  }
271
280
  else if (action === '__confirm') {
272
281
  ux.stdout('');
@@ -308,14 +317,14 @@ export default class DataPgMigrate extends BaseCommand {
308
317
  if (addon) {
309
318
  targetDatabaseId = addon.id;
310
319
  targetDatabaseName = addon.name;
311
- currentStep = this.selectedMigrationMethod === undefined ? '__select_method' : '__confirm_migration';
320
+ currentStep = this.methodProvidedViaFlag ? '__confirm_migration' : '__select_method';
312
321
  }
313
322
  else {
314
323
  currentStep = '__select_target';
315
324
  }
316
325
  }
317
326
  else {
318
- currentStep = this.selectedMigrationMethod === undefined ? '__select_method' : '__confirm_migration';
327
+ currentStep = this.methodProvidedViaFlag ? '__confirm_migration' : '__select_method';
319
328
  }
320
329
  break;
321
330
  }
@@ -6,7 +6,7 @@ import { ensurePGStatStatement, newBlkTimeFields, newTotalExecTimeField } from '
6
6
  import { nls } from '../../nls.js';
7
7
  const heredoc = tsheredoc.default;
8
8
  export async function generateCallsQuery(db, flags) {
9
- await ensurePGStatStatement(db);
9
+ const schema = await ensurePGStatStatement(db);
10
10
  const truncatedQueryString = flags.truncate
11
11
  ? 'CASE WHEN length(query) <= 40 THEN query ELSE substr(query, 0, 39) || \'…\' END'
12
12
  : 'query';
@@ -21,7 +21,7 @@ to_char((${totalExecTimeField}/sum(${totalExecTimeField}) OVER()) * 100, 'FM90D0
21
21
  to_char(calls, 'FM999G999G999G990') AS ncalls,
22
22
  interval '1 millisecond' * (${blkReadField} + ${blkWriteField}) AS sync_io_time,
23
23
  ${truncatedQueryString} AS query
24
- FROM pg_stat_statements WHERE userid = (SELECT usesysid FROM pg_user WHERE usename = current_user LIMIT 1)
24
+ FROM ${schema}.pg_stat_statements WHERE userid = (SELECT usesysid FROM pg_user WHERE usename = current_user LIMIT 1)
25
25
  ORDER BY calls DESC
26
26
  LIMIT 10
27
27
  `.trim();
@@ -13,7 +13,6 @@ export default class Outliers extends Command {
13
13
  };
14
14
  static topic: string;
15
15
  private psqlService;
16
- protected ensurePGStatStatement(): Promise<void>;
17
- protected outliersQuery(version: string | undefined, limit: number, truncate: boolean): string;
16
+ protected outliersQuery(version: string | undefined, limit: number, truncate: boolean, schema: string): string;
18
17
  run(): Promise<void>;
19
18
  }
@@ -2,6 +2,7 @@ import { Command, flags } from '@heroku-cli/command';
2
2
  import { utils } from '@heroku/heroku-cli-util';
3
3
  import { Args, ux } from '@oclif/core';
4
4
  import tsheredoc from 'tsheredoc';
5
+ import { ensurePGStatStatement } from '../../lib/pg/extras.js';
5
6
  import { fetchVersion } from '../../lib/pg/psql.js';
6
7
  import { nls } from '../../nls.js';
7
8
  const heredoc = tsheredoc.default;
@@ -19,24 +20,7 @@ export default class Outliers extends Command {
19
20
  };
20
21
  static topic = 'pg';
21
22
  psqlService;
22
- async ensurePGStatStatement() {
23
- const query = heredoc `
24
- SELECT exists(
25
- SELECT 1
26
- FROM pg_extension e
27
- LEFT JOIN pg_namespace n ON n.oid = e.extnamespace
28
- WHERE e.extname = 'pg_stat_statements' AND n.nspname IN ('public', 'heroku_ext')
29
- ) AS available;
30
- `;
31
- const output = await this.psqlService.execQuery(query);
32
- if (!output.includes('t')) {
33
- ux.error(heredoc `
34
- pg_stat_statements extension need to be installed first.
35
- You can install it by running: CREATE EXTENSION pg_stat_statements WITH SCHEMA heroku_ext;
36
- `);
37
- }
38
- }
39
- outliersQuery(version, limit, truncate) {
23
+ outliersQuery(version, limit, truncate, schema) {
40
24
  const truncatedQueryString = truncate
41
25
  ? heredoc `
42
26
  CASE WHEN length(query) <= 40 THEN query ELSE substr(query, 0, 39) || '…' END
@@ -61,7 +45,7 @@ export default class Outliers extends Command {
61
45
  to_char(calls, 'FM999G999G999G990') AS ncalls,
62
46
  interval '1 millisecond' * (${blkReadTimeField} + ${blkWriteTimeField}) AS sync_io_time,
63
47
  ${truncatedQueryString} AS query
64
- FROM pg_stat_statements
48
+ FROM ${schema}.pg_stat_statements
65
49
  WHERE userid = (
66
50
  SELECT usesysid FROM pg_user WHERE usename = current_user LIMIT 1
67
51
  )
@@ -76,11 +60,11 @@ export default class Outliers extends Command {
76
60
  const db = await dbResolver.getDatabase(app, args.database);
77
61
  this.psqlService = new utils.pg.PsqlService(db);
78
62
  const version = await fetchVersion(db);
79
- await this.ensurePGStatStatement();
63
+ const schema = await ensurePGStatStatement(db);
80
64
  if (reset) {
81
65
  const resetFn = utils.pg.isEssentialDatabase(db.attachment.addon) || utils.pg.isAdvancedDatabase(db.attachment.addon)
82
66
  ? '_heroku.pg_stat_statements_reset()'
83
- : 'pg_stat_statements_reset()';
67
+ : `${schema}.pg_stat_statements_reset()`;
84
68
  await this.psqlService.execQuery(`SELECT ${resetFn};`);
85
69
  return;
86
70
  }
@@ -93,7 +77,7 @@ export default class Outliers extends Command {
93
77
  ux.error(`Cannot parse num param value "${num}" to a number`);
94
78
  }
95
79
  }
96
- const query = this.outliersQuery(version, limit, truncate);
80
+ const query = this.outliersQuery(version, limit, truncate, schema);
97
81
  const output = await this.psqlService.execQuery(query);
98
82
  ux.stdout(output);
99
83
  }
@@ -1,6 +1,7 @@
1
1
  import { HTTP } from '@heroku/http-call';
2
- import { Command, Interfaces } from '@oclif/core';
2
+ import { Interfaces } from '@oclif/core';
3
3
  import HerokulyticsConfig from './herokulytics-config.js';
4
+ import { HerokulyticsData } from './telemetry-utils.js';
4
5
  export interface AnalyticsInterface {
5
6
  event: string;
6
7
  properties: {
@@ -20,7 +21,7 @@ export interface AnalyticsInterface {
20
21
  }
21
22
  export interface RecordOpts {
22
23
  argv: string[];
23
- Command: Command.Class;
24
+ Command: HerokulyticsData['Command'];
24
25
  }
25
26
  export default class BackboardHerokulyticsClient {
26
27
  config: Interfaces.Config;
@@ -51,6 +51,18 @@ export interface TelemetryGlobal {
51
51
  telemetrySent?: boolean;
52
52
  }
53
53
  export type WorkerData = HerokulyticsData | TelemetryData;
54
+ export interface WorkerEnvelope {
55
+ cliVersion: string;
56
+ payload: SerializedWorkerData;
57
+ }
58
+ export interface SerializedError {
59
+ [key: string]: unknown;
60
+ _type: 'error';
61
+ message: string;
62
+ name: string;
63
+ stack?: string;
64
+ }
65
+ export type SerializedWorkerData = HerokulyticsData | SerializedError | Telemetry;
54
66
  /**
55
67
  * Compute duration from a start time to now
56
68
  */
@@ -74,9 +86,21 @@ export declare function getVersion(): string;
74
86
  */
75
87
  export declare function isTelemetryEnabled(): boolean;
76
88
  /**
77
- * Serialize data for telemetry worker, handling Error objects specially
89
+ * Serialize data for the telemetry worker.
90
+ *
91
+ * Every payload is wrapped in a WorkerEnvelope. The worker relies on this
92
+ * shape unconditionally — see parseWorkerEnvelope.
78
93
  */
79
94
  export declare function serializeTelemetryData(data: WorkerData): string;
95
+ /**
96
+ * Parse a worker envelope from stdin. Pure — no side effects.
97
+ *
98
+ * The caller is responsible for applying the envelope's cliVersion via
99
+ * setVersion before any client (Sentry, OTEL) reads getVersion(). Keeping
100
+ * the parse and the state hydration in the caller makes the ordering
101
+ * visible at the call site.
102
+ */
103
+ export declare function parseWorkerEnvelope(input: string): WorkerEnvelope;
80
104
  /**
81
105
  * Set CLI version (called once during setup)
82
106
  */
@@ -83,13 +83,19 @@ export function isTelemetryEnabled() {
83
83
  return true;
84
84
  }
85
85
  /**
86
- * Serialize data for telemetry worker, handling Error objects specially
86
+ * Serialize data for the telemetry worker.
87
+ *
88
+ * Every payload is wrapped in a WorkerEnvelope. The worker relies on this
89
+ * shape unconditionally — see parseWorkerEnvelope.
87
90
  */
88
91
  export function serializeTelemetryData(data) {
89
- // If it's an Error object, convert to plain object with all properties
92
+ const envelope = { cliVersion: getVersion(), payload: toSerializedPayload(data) };
93
+ return JSON.stringify(envelope);
94
+ }
95
+ function toSerializedPayload(data) {
90
96
  if (data instanceof Error) {
91
97
  const errorData = data;
92
- return JSON.stringify({
98
+ return {
93
99
  // Include any other enumerable properties first
94
100
  ...data,
95
101
  // Then override with important properties to ensure they're captured
@@ -102,9 +108,32 @@ export function serializeTelemetryData(data) {
102
108
  oclif: errorData.oclif,
103
109
  stack: errorData.stack,
104
110
  statusCode: errorData.statusCode,
105
- });
111
+ };
112
+ }
113
+ // Discriminate the remaining, non-Error variants of WorkerData by _type
114
+ // so a new variant becomes a compile error here rather than silently
115
+ // falling through.
116
+ switch (data._type) {
117
+ case 'herokulytics':
118
+ case 'otel': {
119
+ return data;
120
+ }
121
+ default: {
122
+ const _exhaustive = data;
123
+ return _exhaustive;
124
+ }
106
125
  }
107
- return JSON.stringify(data);
126
+ }
127
+ /**
128
+ * Parse a worker envelope from stdin. Pure — no side effects.
129
+ *
130
+ * The caller is responsible for applying the envelope's cliVersion via
131
+ * setVersion before any client (Sentry, OTEL) reads getVersion(). Keeping
132
+ * the parse and the state hydration in the caller makes the ordering
133
+ * visible at the call site.
134
+ */
135
+ export function parseWorkerEnvelope(input) {
136
+ return JSON.parse(input);
108
137
  }
109
138
  /**
110
139
  * Set CLI version (called once during setup)
@@ -4,7 +4,7 @@
4
4
  * This runs as a separate process to avoid blocking the main CLI
5
5
  */
6
6
  import { telemetryManager } from './telemetry-manager.js';
7
- import { telemetryDebug } from './telemetry-utils.js';
7
+ import { parseWorkerEnvelope, setVersion, telemetryDebug, } from './telemetry-utils.js';
8
8
  // Set maximum lifetime for worker process (10 seconds)
9
9
  // This ensures the worker never hangs indefinitely due to network issues or other failures
10
10
  const MAX_WORKER_LIFETIME_MS = 10_000;
@@ -46,7 +46,13 @@ process.stdin.on('data', chunk => {
46
46
  });
47
47
  process.stdin.on('end', async () => {
48
48
  try {
49
- const parsed = JSON.parse(inputData);
49
+ const envelope = parseWorkerEnvelope(inputData);
50
+ // Restore the CLI version in this worker's module scope so any client
51
+ // that lazily reads getVersion() (Sentry.init, OTEL tracer) sees the
52
+ // real version instead of 'unknown'. Must happen before the first
53
+ // client import below.
54
+ setVersion(envelope.cliVersion);
55
+ const parsed = envelope.payload;
50
56
  // Check if this is Herokulytics data using explicit type discriminator
51
57
  if (parsed._type === 'herokulytics') {
52
58
  // Handle Herokulytics data
@@ -55,8 +61,7 @@ process.stdin.on('end', async () => {
55
61
  // Recreate Config from serialized data
56
62
  const config = await Config.load();
57
63
  const client = new BackboardHerokulyticsClient(config);
58
- // Send herokulytics data
59
- await client.send(parsed);
64
+ await client.send({ argv: parsed.argv, Command: parsed.Command });
60
65
  exitWorker(0);
61
66
  return;
62
67
  }
@@ -3,7 +3,7 @@ import { pg } from '@heroku/heroku-cli-util';
3
3
  interface Plan {
4
4
  plan: Heroku.AddOn['plan'];
5
5
  }
6
- export declare function ensurePGStatStatement(db: pg.ConnectionDetails): Promise<void>;
6
+ export declare function ensurePGStatStatement(db: pg.ConnectionDetails): Promise<string>;
7
7
  export declare function ensureEssentialTierPlan(db: pg.ConnectionDetails): Promise<void>;
8
8
  export declare function essentialNumPlan(a: Plan): boolean;
9
9
  export declare function newTotalExecTimeField(db: pg.ConnectionDetails): Promise<boolean>;
@@ -2,18 +2,20 @@ import { utils } from '@heroku/heroku-cli-util';
2
2
  import { ux } from '@oclif/core/ux';
3
3
  export async function ensurePGStatStatement(db) {
4
4
  const query = `
5
- SELECT exists(
6
- SELECT 1 FROM pg_extension e LEFT JOIN pg_namespace n ON n.oid = e.extnamespace
7
- WHERE e.extname='pg_stat_statements' AND n.nspname IN ('public', 'heroku_ext')
8
- ) AS available`;
5
+ SELECT quote_ident(n.nspname)
6
+ FROM pg_extension e
7
+ JOIN pg_namespace n ON n.oid = e.extnamespace
8
+ WHERE e.extname = 'pg_stat_statements'`;
9
9
  const psqlService = new utils.pg.PsqlService(db);
10
- const output = await psqlService.execQuery(query);
11
- if (!output.includes('t')) {
12
- ux.error(`pg_stat_statements extension need to be installed in the public schema first.
10
+ const output = await psqlService.execQuery(query, ['-t', '-q']);
11
+ const schema = output.split('\n')[0].trim();
12
+ if (!schema) {
13
+ ux.error(`The pg_stat_statements extension needs to be installed first.
13
14
  You can install it by running:
14
15
 
15
16
  CREATE EXTENSION pg_stat_statements;`, { exit: 1 });
16
17
  }
18
+ return schema;
17
19
  }
18
20
  export async function ensureEssentialTierPlan(db) {
19
21
  const planName = db.attachment?.addon?.plan?.name;
@@ -85,7 +85,8 @@ export default class Dyno extends Duplex {
85
85
  });
86
86
  }
87
87
  async _doStart(retries = 2) {
88
- const command = this.opts['exit-code'] ? `${this.opts.command}; echo "\uFFFF heroku-command-exit-status: $?"` : this.opts.command;
88
+ // Keep a space before `;` so the first token stays a bare process type the runtime can resolve.
89
+ const command = this.opts['exit-code'] ? `${this.opts.command} ; echo "\uFFFF heroku-command-exit-status: $?"` : this.opts.command;
89
90
  try {
90
91
  const dyno = await this.heroku.post(this.opts.dyno ? `/apps/${this.opts.app}/dynos/${this.opts.dyno}` : `/apps/${this.opts.app}/dynos`, {
91
92
  body: {