Skip to content

Commit 84a1bc3

Browse files
dkoss2dkoss
authored andcommitted
feat: adds a pathway for automatic instrumentation
* Exports an autoinstrumnetation library that can be ran, resulting in logs and traces being exported to gcp * a winston logger is automatically created for users of synthetics, isntrumented or otherwise, for use within synthetic code
1 parent 9b48998 commit 84a1bc3

File tree

9 files changed

+3807
-93
lines changed

9 files changed

+3807
-93
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ jobs:
88
runs-on: ubuntu-latest
99
strategy:
1010
matrix:
11-
node: [14, 16, 18, 20]
11+
node: [18, 20]
1212
include:
1313
# use latest npm by default
1414
- npm-version: latest

package-lock.json

Lines changed: 3496 additions & 79 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/synthetics-sdk-api/package.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@types/supertest": "^2.0.12",
1616
"chai": "^4.3.7",
1717
"express": "^4.18.2",
18+
"sinon": "^16.0.0",
1819
"supertest": "^6.3.3"
1920
},
2021
"scripts": {
@@ -30,8 +31,18 @@
3031
"posttest": "npm run lint"
3132
},
3233
"dependencies": {
34+
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.1.0",
35+
"@opentelemetry/api": "^1.6.0",
36+
"@opentelemetry/auto-instrumentations-node": "^0.39.2",
37+
"@opentelemetry/instrumentation": "^0.43.0",
38+
"@opentelemetry/sdk-node": "^0.43.0",
39+
"@opentelemetry/sdk-trace-base": "^1.17.0",
40+
"@opentelemetry/sdk-trace-node": "^1.17.0",
41+
"@types/sinon": "^10.0.16",
3342
"error-stack-parser": "^2.1.4",
34-
"ts-proto": "^1.148.1"
43+
"google-auth-library": "^9.0.0",
44+
"ts-proto": "^1.148.1",
45+
"winston": "^3.10.0"
3546
},
3647
"author": "Google Inc.",
3748
"license": "Apache-2.0"
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright 2023 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
16+
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
17+
import { registerInstrumentations } from '@opentelemetry/instrumentation';
18+
import {
19+
BatchSpanProcessor,
20+
AlwaysOnSampler,
21+
} from '@opentelemetry/sdk-trace-base';
22+
import { Span, TraceFlags } from '@opentelemetry/api';
23+
import { GoogleAuth, GoogleAuthOptions } from 'google-auth-library';
24+
import { Logger } from 'winston';
25+
import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter';
26+
27+
const LOGGING_TRACE_KEY = 'logging.googleapis.com/trace';
28+
const LOGGING_SPAN_KEY = 'logging.googleapis.com/spanId';
29+
const LOGGING_SAMPLED_KEY = 'logging.googleapis.com/trace_sampled';
30+
const LOGGING_SEVERITY_KEY = 'severity';
31+
32+
const levelToSeverityMap: { [key: string]: string } = {
33+
error: 'ERROR',
34+
warn: 'WARNING',
35+
info: 'INFO',
36+
http: 'INFO',
37+
verbose: 'DEBUG',
38+
debug: 'DEBUG',
39+
silly: 'DEBUG',
40+
};
41+
42+
let singletonAutoInstrumentation: SyntheticsAutoInstrumentation | null;
43+
44+
/**
45+
* @public
46+
*
47+
* This function sets up user authored synthetic code with a baseline open
48+
* telemetry setup that will write traces and logs to cloud trace and cloud
49+
* logging.
50+
*
51+
* NOTE: For this module to be used effectively, it needs to be included
52+
* and ran before any other code within your synthetic application runs.
53+
*/
54+
export const instantiateAutoInstrumentation = (
55+
args: { googleAuthOptions?: GoogleAuthOptions } = {}
56+
) => {
57+
singletonAutoInstrumentation = new SyntheticsAutoInstrumentation(args);
58+
};
59+
60+
/**
61+
* @public
62+
*
63+
* Returns a winston logger that is instrumented to use the console transport.
64+
*
65+
* If {@link #instantiateAutoInstrumentation} is ran prior to any other code,
66+
* and a project id is detected according to the logs will be instrumented
67+
* with trace information that is formated in gcp's
68+
* {@link https://cloud.google.com/logging/docs/structured-logging|structured logging}
69+
* format.
70+
*/
71+
export const getInstrumentedLogger = async (): Promise<Logger> => {
72+
if (singletonAutoInstrumentation) {
73+
return await singletonAutoInstrumentation.getInstrumentedLogger();
74+
} else {
75+
const winston = require('winston');
76+
return winston.createLogger({
77+
transports: [new winston.transports.Console()],
78+
});
79+
}
80+
};
81+
82+
class SyntheticsAutoInstrumentation {
83+
provider: NodeTracerProvider;
84+
85+
private logger: Logger;
86+
private gcpProjectId?: string | null;
87+
private authArgs: GoogleAuthOptions;
88+
89+
constructor(args: { googleAuthOptions?: GoogleAuthOptions } = {}) {
90+
this.authArgs = args.googleAuthOptions || {};
91+
92+
this.provider = new NodeTracerProvider({
93+
sampler: new AlwaysOnSampler(),
94+
});
95+
const exporter = new TraceExporter();
96+
this.provider.addSpanProcessor(new BatchSpanProcessor(exporter));
97+
this.provider.register();
98+
99+
// add node auto instrumentation
100+
registerInstrumentations({
101+
instrumentations: [
102+
getNodeAutoInstrumentations({
103+
'@opentelemetry/instrumentation-winston': {
104+
logHook: (span: Span, record: Record<string, string | boolean>) => {
105+
// If the auto instrumentation has detected a project id, convert
106+
// otel fields that are automatically added to the record to use
107+
// structured logging fields instead.
108+
if (this.gcpProjectId) {
109+
record[LOGGING_TRACE_KEY] = `projects/${
110+
this.gcpProjectId
111+
}/traces/${span.spanContext().traceId}`;
112+
record[LOGGING_SPAN_KEY] = span.spanContext().spanId;
113+
record[LOGGING_SAMPLED_KEY] =
114+
span.spanContext().traceFlags === TraceFlags.SAMPLED;
115+
record[LOGGING_SEVERITY_KEY] =
116+
levelToSeverityMap[String(record.level)] ?? 'DEFAULT';
117+
delete record['span_id'];
118+
delete record['trace_flags'];
119+
delete record['level'];
120+
delete record['trace_id'];
121+
}
122+
},
123+
},
124+
}),
125+
],
126+
});
127+
128+
// Require dependencies after instrumentation is registered,
129+
// otherwise they wont be instrumented.
130+
const winston = require('winston');
131+
const logger = winston.createLogger({
132+
transports: [new winston.transports.Console()],
133+
});
134+
this.logger = logger;
135+
}
136+
137+
async getInstrumentedLogger(): Promise<Logger> {
138+
this.gcpProjectId = await resolveProjectId(
139+
this.gcpProjectId,
140+
this.authArgs
141+
);
142+
return this.logger;
143+
}
144+
}
145+
146+
/**
147+
* @public
148+
*
149+
* Resolves and caches the project ID, a field that is required for formatting
150+
* structured logs.
151+
*/
152+
export const resolveProjectId = async (
153+
gcpProjectId?: string | null,
154+
googleAuthOptions?: GoogleAuthOptions
155+
): Promise<string | null> => {
156+
// if gcpProjectId has been instantiated, return it. Otherwise attempt to
157+
// resolve at most 1 times, assign to null otherwise.
158+
if (typeof gcpProjectId === 'string' || gcpProjectId === null) {
159+
return gcpProjectId;
160+
}
161+
162+
const auth = new GoogleAuth({
163+
credentials: googleAuthOptions?.credentials,
164+
keyFile: googleAuthOptions?.keyFile,
165+
keyFilename: googleAuthOptions?.keyFilename,
166+
projectId: googleAuthOptions?.projectId,
167+
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
168+
});
169+
170+
try {
171+
return await auth.getProjectId();
172+
} catch (e) {
173+
console.log(
174+
'Unable to resolve gcpProjectId, logs will not be written in GCP Structured Logging format'
175+
);
176+
}
177+
178+
return null;
179+
};

packages/synthetics-sdk-api/src/handlers.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,24 @@ import {
2323
getRuntimeMetadata,
2424
instantiateMetadata,
2525
} from './runtime_metadata_extractor';
26+
import { Logger } from 'winston';
2627

28+
import { getInstrumentedLogger } from './auto_instrumentation';
2729
instantiateMetadata();
2830

2931
const asyncFilenamePrefix = 'async ';
30-
31-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
32-
const runSynthetic = async (syntheticCode: () => any) => {
32+
const runSynthetic = async (
33+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
34+
syntheticCode: (args: { logger: Logger }) => any
35+
) => {
36+
const logger = await getInstrumentedLogger();
3337
const startTime = new Date().toISOString();
3438

3539
const syntheticResult = SyntheticResult.create();
3640
const synthetic_generic_result = GenericResultV1.create();
3741

3842
try {
39-
await syntheticCode();
43+
await syntheticCode({ logger });
4044
synthetic_generic_result.ok = true;
4145
} catch (err: unknown) {
4246
synthetic_generic_result.ok = false;
@@ -88,8 +92,10 @@ const runSynthetic = async (syntheticCode: () => any) => {
8892
* @returns ExpressJS compatible middleware that invokes SyntheticsSDK mocha, and
8993
* returns the results via res.send
9094
*/
91-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
92-
export function runSyntheticHandler(syntheticCode: () => any) {
95+
export function runSyntheticHandler(
96+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
97+
syntheticCode: (args: { logger: Logger }) => any
98+
) {
9399
// eslint-disable-next-line @typescript-eslint/no-explicit-any
94100
return async (req: Request, res: Response): Promise<any> =>
95101
res.send(await runSynthetic(syntheticCode));

packages/synthetics-sdk-api/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,14 @@
1515
/**
1616
* @public
1717
*/
18+
1819
export * from './generated/proto/synthetic_response';
1920
export { runSyntheticHandler } from './handlers';
2021
export {
2122
getRuntimeMetadata,
2223
instantiateMetadata,
2324
} from './runtime_metadata_extractor';
25+
export {
26+
instantiateAutoInstrumentation,
27+
getInstrumentedLogger,
28+
} from './auto_instrumentation';

packages/synthetics-sdk-api/test/example_test_files/integration_server.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,20 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
const { runSyntheticHandler, instantiateAutoInstrumentation } = require('../../src/index');
16+
instantiateAutoInstrumentation();
1517
const functions = require('@google-cloud/functions-framework');
16-
const { runSyntheticHandler } = require('@google-cloud/synthetics-sdk-api');
1718
import { AssertionError } from 'chai';
1819

19-
functions.http('SyntheticOk', runSyntheticHandler(async () => {
20+
functions.http('SyntheticOk', runSyntheticHandler(async ({logger}) => {
21+
logger.info('This is a log');
2022
return await true;
2123
}));
2224

23-
functions.http('SyntheticNotOk', runSyntheticHandler(async () => {
25+
functions.http('SyntheticNotOk', runSyntheticHandler(async ({logger}) => {
2426
const e = new AssertionError('Did not assert');
2527
const splitStack = e.stack?.split('\n', 2) ?? '';
28+
logger.error('This is an error log');
2629
e.stack = [
2730
splitStack?.[0],
2831
' at internalFn (node:internal)',

packages/synthetics-sdk-api/test/integration/integration.spec.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,63 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
import { SyntheticResult } from '../../src'
15+
require('../../test/example_test_files/integration_server.js');
16+
import { SyntheticResult, TestFrameworkResultV1 } from '../../src';
1617
import { expect } from 'chai';
1718
import supertest from 'supertest';
1819

19-
require('../../test/example_test_files/integration_server.js');
20+
import { Writable } from 'stream';
21+
import * as sinon from 'sinon';
22+
import winston, { Logger } from 'winston';
23+
2024
const { getTestServer } = require('@google-cloud/functions-framework/testing');
25+
const { getInstrumentedLogger } = require('../../src/auto_instrumentation');
26+
27+
let logger: Logger;
28+
let writeSpy: sinon.SinonSpy;
29+
30+
const traceVersion = '00';
31+
const traceId = '12345678901234567890123456789012';
32+
const traceParentId = '1234567890123456';
33+
const traceFlags = '01';
34+
const traceParentHeader = [traceVersion, traceId, traceParentId, traceFlags].join('-');
2135

2236
describe('CloudFunctionV2 Running Synthetics', async () => {
37+
beforeEach(async () => {
38+
const stream = new Writable();
39+
stream._write = () => {};
40+
writeSpy = sinon.spy(stream, '_write');
41+
42+
sinon.replace(process, 'env', {GCLOUD_PROJECT: 'project-id'});
43+
44+
logger = await getInstrumentedLogger();
45+
46+
const streamTransport = new winston.transports.Stream({ stream });
47+
logger.add(streamTransport);
48+
});
49+
50+
afterEach(async () => {
51+
sinon.restore();
52+
});
53+
2354
it('runs a passing synthetic function', async () => {
2455
const server = getTestServer('SyntheticOk');
25-
2656
const response = await supertest(server)
2757
.get('/')
2858
.send()
2959
.set('Content-Type', 'application/json')
60+
.set('traceparent', traceParentHeader)
3061
.expect(200);
3162

63+
sinon.assert.calledOnce(writeSpy);
64+
65+
const record = JSON.parse(writeSpy.firstCall.args[0].toString());
66+
expect(record['severity']).to.equal('INFO');
67+
expect(record['message']).to.equal('This is a log');
68+
expect(record['logging.googleapis.com/spanId']).to.equal('1234567890123456');
69+
expect(record['logging.googleapis.com/trace']).to.equal('projects/project-id/traces/12345678901234567890123456789012');
70+
expect(record['logging.googleapis.com/trace_sampled']).to.equal(true);
71+
3272
const output: SyntheticResult = response.body as SyntheticResult;
3373
const start_time = output.start_time;
3474
const end_time = output.end_time;
@@ -51,8 +91,18 @@ describe('CloudFunctionV2 Running Synthetics', async () => {
5191
.get('/')
5292
.send()
5393
.set('Content-Type', 'application/json')
94+
.set('traceparent', traceParentHeader)
5495
.expect(200);
5596

97+
sinon.assert.calledOnce(writeSpy);
98+
99+
const record = JSON.parse(writeSpy.firstCall.args[0].toString());
100+
expect(record['message']).to.equal('This is an error log');
101+
expect(record['severity']).to.equal('ERROR');
102+
expect(record['logging.googleapis.com/spanId']).to.equal('1234567890123456');
103+
expect(record['logging.googleapis.com/trace']).to.equal('projects/project-id/traces/12345678901234567890123456789012');
104+
expect(record['logging.googleapis.com/trace_sampled']).to.equal(true);
105+
56106
const output: SyntheticResult = response.body as SyntheticResult;
57107
const start_time = output.start_time;
58108
const end_time = output.end_time;

0 commit comments

Comments
 (0)