-
Notifications
You must be signed in to change notification settings - Fork 119
feat(cli): send telemetry from a detached subprocess to unblock CLI exit #1779
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sanjanaravikumar-az
wants to merge
20
commits into
main
Choose a base branch
from
sanjrkmr/telemetry-subprocess
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
e91f30a
feat(cli): send telemetry from a detached subprocess
0c31c3d
test(cli): cover the detached telemetry sender
b710b27
chore(cli): address telemetry sender review feedback (accurate trace,…
8d41011
Merge branch 'main' into sanjrkmr/telemetry-subprocess
sanjanaravikumar-az adfa346
fix(cli): give detached telemetry sender a realistic network timeout …
94d0305
fix(cli): enforce endpoint TLS identity on the proxied telemetry path…
0ebb2c4
Merge branch 'main' into sanjrkmr/telemetry-subprocess
sanjanaravikumar-az 147ad84
Merge branch 'main' into sanjrkmr/telemetry-subprocess
sanjanaravikumar-az 6977aaf
Merge branch 'main' into sanjrkmr/telemetry-subprocess
sanjanaravikumar-az f76604c
Merge branch 'main' into sanjrkmr/telemetry-subprocess
sanjanaravikumar-az 5a8df75
Merge branch 'main' into sanjrkmr/telemetry-subprocess
sanjanaravikumar-az ccd9f1e
Merge branch 'main' into sanjrkmr/telemetry-subprocess
sanjanaravikumar-az c971336
refactor(cli): bundle the telemetry sender and forward the CA path, n…
sanjanaravikumar-az 3fc8889
feat(cli): make fire-and-forget telemetry delivery observable
sanjanaravikumar-az d7b09e3
test(cli): assert telemetry actually arrives, not that we said we sen…
sanjanaravikumar-az 6ac52d1
docs(cli): trim the telemetry comments and document the debug variable
sanjanaravikumar-az ea02d99
refactor(cli): phase 5 review cleanup for the detached telemetry sender
sanjanaravikumar-az be0542f
fix(cli): phase 6 review fixes for the detached telemetry sender
sanjanaravikumar-az cca1889
Merge branch 'main' into sanjrkmr/telemetry-subprocess
sanjanaravikumar-az c47a1b7
fix(cli): make the SOCKS unit tests and telemetry integ tests CI-safe
sanjanaravikumar-az File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
189 changes: 189 additions & 0 deletions
189
packages/@aws-cdk-testing/cli-integ/lib/telemetry-endpoint.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| import { promises as fs } from 'fs'; | ||
| import * as net from 'net'; | ||
| import * as os from 'os'; | ||
| import * as path from 'path'; | ||
| import * as mockttp from 'mockttp'; | ||
| import { sleep } from './aws'; | ||
|
|
||
| /** | ||
| * A local stand-in for the telemetry endpoint. | ||
| * | ||
| * Built on the same mockttp machinery as `startProxyServer`, which matters for one specific reason: | ||
| * mockttp mints a leaf certificate for the requested host signed by the CA we hand it, so the CLI | ||
| * can be pointed at `caBundlePath` with `--ca-bundle-path` and the delivery will actually complete a | ||
| * TLS handshake. A bare self-signed certificate would fail hostname verification instead. | ||
| * | ||
| * Dispose it in a `finally` block. | ||
| */ | ||
| export interface TelemetryEndpoint { | ||
| /** | ||
| * URL to point `TELEMETRY_ENDPOINT` at. | ||
| */ | ||
| readonly url: string; | ||
|
|
||
| /** | ||
| * Path to the CA certificate that signs this endpoint's certificate. | ||
| * | ||
| * Pass through `NODE_EXTRA_CA_CERTS` so the CLI, and the detached sender it spawns, will trust it. | ||
| * | ||
| * Deliberately NOT `--ca-bundle-path` or `AWS_CA_BUNDLE`: those REPLACE the trust store for the | ||
| * whole CLI, so the SDK's own calls to public AWS endpoints stop verifying. `STS.GetCallerIdentity` | ||
| * then fails to find an issuer, the default account never resolves, and the app exits non-zero | ||
| * before any telemetry assertion is reached. `NODE_EXTRA_CA_CERTS` adds to the store instead. | ||
| */ | ||
| readonly caBundlePath: string; | ||
|
|
||
| /** | ||
| * Every telemetry batch this endpoint has received so far. | ||
| */ | ||
| batches(): Promise<TelemetryBatch[]>; | ||
|
|
||
| /** | ||
| * Wait for at least one batch to arrive. | ||
| * | ||
| * Delivery happens in a detached child that outlives the CLI, so tests have to poll rather than | ||
| * assert immediately after the command returns. | ||
| * | ||
| * @returns the first batch, or undefined if none arrived in time | ||
| */ | ||
| waitForBatch(timeoutMs?: number): Promise<TelemetryBatch | undefined>; | ||
|
|
||
| dispose(): Promise<void>; | ||
| } | ||
|
|
||
| /** | ||
| * A batch of events as the endpoint received it. | ||
| */ | ||
| export interface TelemetryBatch { | ||
| readonly events: Array<Record<string, any>>; | ||
| } | ||
|
|
||
| /** | ||
| * Options for `startTelemetryEndpoint`. | ||
| */ | ||
| export interface TelemetryEndpointOptions { | ||
| /** | ||
| * Status code to answer with. | ||
| * | ||
| * @default 200 | ||
| */ | ||
| readonly statusCode?: number; | ||
|
|
||
| /** | ||
| * Where to put the generated certificate directory. | ||
| * | ||
| * @default the OS temp directory | ||
| */ | ||
| readonly certDirRoot?: string; | ||
| } | ||
|
|
||
| export async function startTelemetryEndpoint(options: TelemetryEndpointOptions = {}): Promise<TelemetryEndpoint> { | ||
| const certDir = await fs.mkdtemp(path.join(options.certDirRoot ?? os.tmpdir(), 'cdk-telemetry-')); | ||
| const certPath = path.join(certDir, 'cert.pem'); | ||
| const keyPath = path.join(certDir, 'key.pem'); | ||
|
|
||
| const { key, cert } = await mockttp.generateCACertificate(); | ||
| await fs.writeFile(keyPath, key); | ||
| await fs.writeFile(certPath, cert); | ||
|
|
||
| const server = mockttp.getLocal({ https: { keyPath, certPath } }); | ||
| const endpoint = await server.forPost('/metrics').thenReply( | ||
| options.statusCode ?? 200, | ||
| JSON.stringify({ ok: true }), | ||
| { 'content-type': 'application/json' }, | ||
| ); | ||
|
|
||
| // No port argument: mockttp picks a free one itself. Naming a port -- even a random one out of a | ||
| // range -- collides once suites run in parallel, and there is no retry to recover from it. | ||
| await server.start(); | ||
|
|
||
| const batches = async (): Promise<TelemetryBatch[]> => { | ||
| const requests = await endpoint.getSeenRequests(); | ||
| return requests.map((req) => JSON.parse(req.body.buffer.toString('utf-8')) as TelemetryBatch); | ||
| }; | ||
|
|
||
| return { | ||
| // `localhost` rather than 127.0.0.1: the certificate mockttp mints covers the hostname, and this | ||
| // is the name the sender will verify against. | ||
| url: `https://localhost:${server.port}/metrics`, | ||
| caBundlePath: certPath, | ||
| batches, | ||
| waitForBatch: (timeoutMs = 30_000) => waitFor(async () => (await batches())[0], timeoutMs), | ||
| async dispose() { | ||
| await server.stop(); | ||
| await fs.rm(certDir, { recursive: true, force: true }); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * A TCP listener that accepts connections and then never answers. | ||
| * | ||
| * Stands in for an endpoint that hangs, which is how we tell "the CLI did not wait for delivery" | ||
| * apart from "delivery happened to be fast". | ||
| * | ||
| * Dispose it in a `finally` block. | ||
| */ | ||
| export interface BlackHoleEndpoint { | ||
| /** | ||
| * URL to point `TELEMETRY_ENDPOINT` at. | ||
| */ | ||
| readonly url: string; | ||
|
|
||
| /** | ||
| * How many connections have been accepted. | ||
| * | ||
| * A non-zero count is what proves delivery was actually attempted rather than skipped. | ||
| */ | ||
| connectionCount(): number; | ||
|
|
||
| /** | ||
| * Wait for at least one connection to arrive. | ||
| */ | ||
| waitForConnection(timeoutMs?: number): Promise<boolean>; | ||
|
|
||
| dispose(): Promise<void>; | ||
| } | ||
|
|
||
| export async function startBlackHoleEndpoint(): Promise<BlackHoleEndpoint> { | ||
| const sockets: net.Socket[] = []; | ||
| let connections = 0; | ||
|
|
||
| const server = net.createServer((socket) => { | ||
| connections += 1; | ||
| sockets.push(socket); | ||
| }); | ||
| await new Promise<void>((ok) => server.listen(0, '127.0.0.1', ok)); | ||
| const port = (server.address() as net.AddressInfo).port; | ||
|
|
||
| return { | ||
| url: `https://127.0.0.1:${port}/metrics`, | ||
| connectionCount: () => connections, | ||
| waitForConnection: (timeoutMs = 30_000) => waitFor(async () => connections > 0 || undefined, timeoutMs).then((x) => x === true), | ||
| async dispose() { | ||
| for (const socket of sockets) { | ||
| socket.destroy(); | ||
| } | ||
| await new Promise<void>((ok) => server.close(() => ok())); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Poll `fn` until it returns something truthy, or give up after `timeoutMs`. | ||
| * | ||
| * Deliberately not `eventually` from `./eventually`: that one retries until a call stops THROWING and | ||
| * rethrows on give-up, whereas both callers here want "returned nothing within the deadline" to be a | ||
| * plain undefined they can assert on. | ||
| */ | ||
| export async function waitFor<A>(fn: () => Promise<A | undefined>, timeoutMs: number): Promise<A | undefined> { | ||
| const deadline = Date.now() + timeoutMs; | ||
| while (Date.now() < deadline) { | ||
| const result = await fn(); | ||
| if (result) { | ||
| return result; | ||
| } | ||
| await sleep(500); | ||
| } | ||
| return undefined; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
51 changes: 51 additions & 0 deletions
51
...nteg/tests/telemetry-integ-tests/cdk-telemetry-disable-command-posts-nothing.integtest.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import { TELEMETRY_QUIET_PERIOD_MS } from './constants'; | ||
| import { integTest, sleep, withDefaultFixture } from '../../lib'; | ||
| import { startTelemetryEndpoint } from '../../lib/telemetry-endpoint'; | ||
|
|
||
| /** | ||
| * Opting out via the persisted setting has to actually stop the data leaving the machine. | ||
| * | ||
| * `cli-telemetry --disable` writes to the CDK context rather than reading an environment variable, so | ||
| * it reaches the same decision by a different route than | ||
| * `cdk-telemetry-disabled-posts-nothing`. Proven the same way: a real local endpoint, and nothing | ||
| * POSTed to it by the CLI or by the detached child that outlives it. | ||
| * | ||
| * The endpoint's CA is still supplied, via `NODE_EXTRA_CA_CERTS`, even though nothing should reach it: | ||
| * without a trusted CA "nothing arrived" would also be true of an ENABLED run whose TLS handshake | ||
| * simply failed, and the test would pass for the wrong reason. Not `--ca-bundle-path`, which REPLACES | ||
| * the trust store and breaks the SDK's own calls to public AWS endpoints. | ||
| */ | ||
| integTest( | ||
| 'cli-telemetry --disable posts nothing to the endpoint', | ||
| withDefaultFixture(async (fixture) => { | ||
| const endpoint = await startTelemetryEndpoint({ certDirRoot: fixture.integTestDir }); | ||
| try { | ||
| await fixture.cdk(['cli-telemetry', '--disable'], { | ||
| modEnv: { | ||
| CDK_HOME: fixture.integTestDir, | ||
| TELEMETRY_ENDPOINT: endpoint.url, | ||
| }, | ||
| }); | ||
|
|
||
| const output = await fixture.cdkSynth({ | ||
| options: [ | ||
| fixture.fullStackName('test-1'), | ||
| ], | ||
| modEnv: { | ||
| CDK_HOME: fixture.integTestDir, | ||
| TELEMETRY_ENDPOINT: endpoint.url, | ||
| NODE_EXTRA_CA_CERTS: endpoint.caBundlePath, | ||
| }, | ||
| verboseLevel: 3, // trace | ||
| }); | ||
|
|
||
| expect(output).toContain('Telemetry disabled'); | ||
|
|
||
| await sleep(TELEMETRY_QUIET_PERIOD_MS); | ||
|
|
||
| expect(await endpoint.batches()).toEqual([]); | ||
| } finally { | ||
| await endpoint.dispose(); | ||
| } | ||
| }), | ||
| ); |
45 changes: 45 additions & 0 deletions
45
...g/cli-integ/tests/telemetry-integ-tests/cdk-telemetry-disabled-posts-nothing.integtest.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { TELEMETRY_QUIET_PERIOD_MS } from './constants'; | ||
| import { integTest, sleep, withDefaultFixture } from '../../lib'; | ||
| import { startTelemetryEndpoint } from '../../lib/telemetry-endpoint'; | ||
|
|
||
| /** | ||
| * Opting out via the environment has to actually stop the data leaving the machine. | ||
| * | ||
| * The other disable tests assert on the CLI's own trace output, which only proves the sink was never | ||
| * constructed. This points `TELEMETRY_ENDPOINT` at a real local server and proves nothing is POSTed | ||
| * to it -- including by the detached child, which outlives the CLI and would therefore not show up in | ||
| * its output at all. | ||
| * | ||
| * The endpoint's CA is still supplied, via `NODE_EXTRA_CA_CERTS`, even though nothing should reach it: | ||
| * without a trusted CA "nothing arrived" would also be true of an ENABLED run whose TLS handshake | ||
| * simply failed, and the test would pass for the wrong reason. Not `--ca-bundle-path`, which REPLACES | ||
| * the trust store and breaks the SDK's own calls to public AWS endpoints. | ||
| */ | ||
| integTest( | ||
| 'CDK_DISABLE_CLI_TELEMETRY posts nothing to the endpoint', | ||
| withDefaultFixture(async (fixture) => { | ||
| const endpoint = await startTelemetryEndpoint({ certDirRoot: fixture.integTestDir }); | ||
| try { | ||
| const output = await fixture.cdkSynth({ | ||
| options: [ | ||
| fixture.fullStackName('test-1'), | ||
| ], | ||
| modEnv: { | ||
| CDK_HOME: fixture.integTestDir, | ||
| TELEMETRY_ENDPOINT: endpoint.url, | ||
| NODE_EXTRA_CA_CERTS: endpoint.caBundlePath, | ||
| CDK_DISABLE_CLI_TELEMETRY: 'true', | ||
| }, | ||
| verboseLevel: 3, // trace | ||
| }); | ||
|
|
||
| expect(output).toContain('Telemetry disabled'); | ||
|
|
||
| await sleep(TELEMETRY_QUIET_PERIOD_MS); | ||
|
|
||
| expect(await endpoint.batches()).toEqual([]); | ||
| } finally { | ||
| await endpoint.dispose(); | ||
| } | ||
| }), | ||
| ); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But now our tests won't ensure telemetry is actually being sent - asserting on a dispatch is not enough. We need a way for the sender to communicate back to the test that the telemetry endpoint responded with 200.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What we probably need is to stand up an HTTP server, set that as the telemetry endpoint, then assert on what gets sent to that endpoint.
Or in this case, we need to assert that after X seconds, we still didn't get any data POSTed to that endpoint.
And that holds for all tests, it will be a better one than asserting on the log line.