Skip to content

Commit 4c14220

Browse files
authored
chore(cli): telemetry sink (#585)
Introduces a generic telemetry sink to the cli. it is tested but not used anywhere. There are 3 base types of telemetry sinks: - `FileSink` -> gathers events and writes them to a file - `IoHostSink` -> gathers events and sends them to the IoHost (for writing to stdout/err) - `EndpointSink` -> gathers events and sends them to an external endpoint, batching at intervals of 30 seconds. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent 6a4d423 commit 4c14220

File tree

8 files changed

+1043
-0
lines changed

8 files changed

+1043
-0
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import type { IncomingMessage } from 'http';
2+
import type { Agent } from 'https';
3+
import { request } from 'https';
4+
import type { UrlWithStringQuery } from 'url';
5+
import { ToolkitError } from '@aws-cdk/toolkit-lib';
6+
import { IoHelper } from '../../api-private';
7+
import type { IIoHost } from '../io-host';
8+
import type { TelemetrySchema } from './schema';
9+
import type { ITelemetrySink } from './sink-interface';
10+
11+
const REQUEST_ATTEMPT_TIMEOUT_MS = 500;
12+
13+
/**
14+
* Properties for the Endpoint Telemetry Client
15+
*/
16+
export interface EndpointTelemetrySinkProps {
17+
/**
18+
* The external endpoint to hit
19+
*/
20+
readonly endpoint: UrlWithStringQuery;
21+
22+
/**
23+
* Where messages are going to be sent
24+
*/
25+
readonly ioHost: IIoHost;
26+
27+
/**
28+
* The agent responsible for making the network requests.
29+
*
30+
* Use this to set up a proxy connection.
31+
*
32+
* @default - Uses the shared global node agent
33+
*/
34+
readonly agent?: Agent;
35+
}
36+
37+
/**
38+
* The telemetry client that hits an external endpoint.
39+
*/
40+
export class EndpointTelemetrySink implements ITelemetrySink {
41+
private events: TelemetrySchema[] = [];
42+
private endpoint: UrlWithStringQuery;
43+
private ioHelper: IoHelper;
44+
private agent?: Agent;
45+
46+
public constructor(props: EndpointTelemetrySinkProps) {
47+
this.endpoint = props.endpoint;
48+
this.ioHelper = IoHelper.fromActionAwareIoHost(props.ioHost);
49+
this.agent = props.agent;
50+
51+
// Batch events every 30 seconds
52+
setInterval(() => this.flush(), 30000).unref();
53+
}
54+
55+
/**
56+
* Add an event to the collection.
57+
*/
58+
public async emit(event: TelemetrySchema): Promise<void> {
59+
try {
60+
this.events.push(event);
61+
} catch (e: any) {
62+
// Never throw errors, just log them via ioHost
63+
await this.ioHelper.defaults.trace(`Failed to add telemetry event: ${e.message}`);
64+
}
65+
}
66+
67+
public async flush(): Promise<void> {
68+
try {
69+
if (this.events.length === 0) {
70+
return;
71+
}
72+
73+
const res = await this.https(this.endpoint, this.events);
74+
75+
// Clear the events array after successful output
76+
if (res) {
77+
this.events = [];
78+
}
79+
} catch (e: any) {
80+
// Never throw errors, just log them via ioHost
81+
await this.ioHelper.defaults.trace(`Failed to add telemetry event: ${e.message}`);
82+
}
83+
}
84+
85+
/**
86+
* Returns true if telemetry successfully posted, false otherwise.
87+
*/
88+
private async https(
89+
url: UrlWithStringQuery,
90+
body: TelemetrySchema[],
91+
): Promise<boolean> {
92+
try {
93+
const res = await doRequest(url, body, this.agent);
94+
95+
// Successfully posted
96+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
97+
return true;
98+
}
99+
100+
await this.ioHelper.defaults.trace(`Telemetry Unsuccessful: POST ${url.hostname}${url.pathname}: ${res.statusCode}:${res.statusMessage}`);
101+
102+
return false;
103+
} catch (e: any) {
104+
await this.ioHelper.defaults.trace(`Telemetry Error: POST ${url.hostname}${url.pathname}: ${JSON.stringify(e)}`);
105+
return false;
106+
}
107+
}
108+
}
109+
110+
/**
111+
* A Promisified version of `https.request()`
112+
*/
113+
function doRequest(
114+
url: UrlWithStringQuery,
115+
data: TelemetrySchema[],
116+
agent?: Agent,
117+
) {
118+
return new Promise<IncomingMessage>((ok, ko) => {
119+
const payload: string = JSON.stringify(data);
120+
const req = request({
121+
hostname: url.hostname,
122+
port: url.port,
123+
path: url.pathname,
124+
method: 'POST',
125+
headers: {
126+
'content-type': 'application/json',
127+
'content-length': payload.length,
128+
},
129+
agent,
130+
timeout: REQUEST_ATTEMPT_TIMEOUT_MS,
131+
}, ok);
132+
133+
req.on('error', ko);
134+
req.on('timeout', () => {
135+
const error = new ToolkitError(`Timeout after ${REQUEST_ATTEMPT_TIMEOUT_MS}ms, aborting request`);
136+
req.destroy(error);
137+
});
138+
139+
req.end(payload);
140+
});
141+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import { ToolkitError, type IIoHost } from '@aws-cdk/toolkit-lib';
4+
import type { TelemetrySchema } from './schema';
5+
import type { ITelemetrySink } from './sink-interface';
6+
import { IoHelper } from '../../api-private';
7+
8+
/**
9+
* Properties for the FileTelemetryClient
10+
*/
11+
export interface FileTelemetrySinkProps {
12+
/**
13+
* Where messages are going to be sent
14+
*/
15+
readonly ioHost: IIoHost;
16+
17+
/**
18+
* The local file to log telemetry data to.
19+
*/
20+
readonly logFilePath: string;
21+
}
22+
23+
/**
24+
* A telemetry client that collects events writes them to a file
25+
*/
26+
export class FileTelemetrySink implements ITelemetrySink {
27+
private ioHelper: IoHelper;
28+
private logFilePath: string;
29+
30+
/**
31+
* Create a new FileTelemetryClient
32+
*/
33+
constructor(props: FileTelemetrySinkProps) {
34+
this.ioHelper = IoHelper.fromActionAwareIoHost(props.ioHost);
35+
this.logFilePath = props.logFilePath;
36+
37+
if (fs.existsSync(this.logFilePath)) {
38+
throw new ToolkitError(`Telemetry file already exists at ${this.logFilePath}`);
39+
}
40+
41+
// Create the file if necessary
42+
const directory = path.dirname(this.logFilePath);
43+
if (!fs.existsSync(directory)) {
44+
fs.mkdirSync(directory, { recursive: true });
45+
}
46+
}
47+
48+
/**
49+
* Emit an event.
50+
*/
51+
public async emit(event: TelemetrySchema): Promise<void> {
52+
try {
53+
// Format the events as a JSON string with pretty printing
54+
const output = JSON.stringify(event, null, 2) + '\n';
55+
56+
// Write to file
57+
fs.appendFileSync(this.logFilePath, output);
58+
} catch (e: any) {
59+
// Never throw errors, just log them via ioHost
60+
await this.ioHelper.defaults.trace(`Failed to add telemetry event: ${e.message}`);
61+
}
62+
}
63+
64+
public async flush(): Promise<void> {
65+
return;
66+
}
67+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { IIoHost } from '@aws-cdk/toolkit-lib';
2+
import type { TelemetrySchema } from './schema';
3+
import type { ITelemetrySink } from './sink-interface';
4+
import { IoHelper } from '../../api-private';
5+
6+
/**
7+
* Properties for the StdoutTelemetryClient
8+
*/
9+
export interface IoHostTelemetrySinkProps {
10+
/**
11+
* Where messages are going to be sent
12+
*/
13+
readonly ioHost: IIoHost;
14+
}
15+
16+
/**
17+
* A telemetry client that collects events and flushes them to stdout.
18+
*/
19+
export class IoHostTelemetrySink implements ITelemetrySink {
20+
private ioHelper: IoHelper;
21+
22+
/**
23+
* Create a new StdoutTelemetryClient
24+
*/
25+
constructor(props: IoHostTelemetrySinkProps) {
26+
this.ioHelper = IoHelper.fromActionAwareIoHost(props.ioHost);
27+
}
28+
29+
/**
30+
* Emit an event
31+
*/
32+
public async emit(event: TelemetrySchema): Promise<void> {
33+
try {
34+
// Format the events as a JSON string with pretty printing
35+
const output = JSON.stringify(event, null, 2);
36+
37+
// Write to IoHost
38+
await this.ioHelper.defaults.trace(`--- TELEMETRY EVENT ---\n${output}\n-----------------------\n`);
39+
} catch (e: any) {
40+
// Never throw errors, just log them via ioHost
41+
await this.ioHelper.defaults.trace(`Failed to add telemetry event: ${e.message}`);
42+
}
43+
}
44+
45+
public async flush(): Promise<void> {
46+
return;
47+
}
48+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
interface Identifiers {
2+
readonly cdkCliVersion: string;
3+
readonly cdkLibraryVersion?: string;
4+
readonly telemetryVersion: string;
5+
readonly sessionId: string;
6+
readonly eventId: string;
7+
readonly installationId: string;
8+
readonly timestamp: string;
9+
readonly accountId?: string;
10+
readonly region?: string;
11+
}
12+
13+
interface Event {
14+
readonly state: 'ABORTED' | 'FAILED' | 'SUCCEEDED';
15+
readonly eventType: string;
16+
readonly command: {
17+
readonly path: string[];
18+
readonly parameters: string[];
19+
readonly config: { [key: string]: any };
20+
};
21+
}
22+
23+
interface Environment {
24+
readonly os: {
25+
readonly platform: string;
26+
readonly release: string;
27+
};
28+
readonly ci: boolean;
29+
readonly nodeVersion: string;
30+
}
31+
32+
interface Duration {
33+
readonly total: number;
34+
readonly components?: { [key: string]: number };
35+
}
36+
37+
type Counters = { [key: string]: number };
38+
39+
interface Error {
40+
readonly name: string;
41+
readonly message?: string; // anonymized stack message
42+
readonly trace?: string; // anonymized stack trace
43+
readonly logs?: string; // anonymized stack logs
44+
}
45+
46+
interface Dependency {
47+
readonly name: string;
48+
readonly version: string;
49+
}
50+
51+
interface Project {
52+
readonly dependencies?: Dependency[];
53+
}
54+
55+
export interface TelemetrySchema {
56+
readonly identifiers: Identifiers;
57+
readonly event: Event;
58+
readonly environment: Environment;
59+
readonly project: Project;
60+
readonly duration: Duration;
61+
readonly counters?: Counters;
62+
readonly error?: Error;
63+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { TelemetrySchema } from './schema';
2+
3+
/**
4+
* All Telemetry Clients are Sinks.
5+
*
6+
* A telemtry client receives event data via 'emit'
7+
* and sends batched events via 'flush'
8+
*/
9+
export interface ITelemetrySink {
10+
/**
11+
* Recieve an event
12+
*/
13+
emit(event: TelemetrySchema): Promise<void>;
14+
15+
/**
16+
* If the implementer of ITelemetrySink batches events,
17+
* flush sends the data and clears the cache.
18+
*/
19+
flush(): Promise<void>;
20+
}

0 commit comments

Comments
 (0)