Skip to content

Commit 1c48074

Browse files
authored
feat: add execution id support (#592)
When set LOG_EXECUTION_ID=True in environment variables or set --log-execution-id, execution id will be added to log along with trace and span id. Nodejs version 13.0.0 is required for this feature.
1 parent def93da commit 1c48074

18 files changed

+700
-20
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ ignored.
190190
| `--target` | `FUNCTION_TARGET` | The name of the exported function to be invoked in response to requests. Default: `function` |
191191
| `--signature-type` | `FUNCTION_SIGNATURE_TYPE` | The signature used when writing your function. Controls unmarshalling rules and determines which arguments are used to invoke your function. Default: `http`; accepted values: `http` or `event` or `cloudevent` |
192192
| `--source` | `FUNCTION_SOURCE` | The path to the directory of your function. Default: `cwd` (the current working directory) |
193+
| `--log-execution-id`| `LOG_EXECUTION_ID` | Enables execution IDs in logs, either `true` or `false`. When not specified, default to disable. Requires Node.js 13.0.0 or later. |
193194
194195
You can set command-line flags in your `package.json` via the `start` script.
195196
For example:

docs/generated/api.json

+81
Original file line numberDiff line numberDiff line change
@@ -1757,6 +1757,33 @@
17571757
"name": "Request_2",
17581758
"preserveMemberOrder": false,
17591759
"members": [
1760+
{
1761+
"kind": "PropertySignature",
1762+
"canonicalReference": "@google-cloud/functions-framework!Request_2#executionId:member",
1763+
"docComment": "/**\n * Request-specified execution ID.\n */\n",
1764+
"excerptTokens": [
1765+
{
1766+
"kind": "Content",
1767+
"text": "executionId?: "
1768+
},
1769+
{
1770+
"kind": "Content",
1771+
"text": "string"
1772+
},
1773+
{
1774+
"kind": "Content",
1775+
"text": ";"
1776+
}
1777+
],
1778+
"isReadonly": false,
1779+
"isOptional": true,
1780+
"releaseTag": "Public",
1781+
"name": "executionId",
1782+
"propertyTypeTokenRange": {
1783+
"startIndex": 1,
1784+
"endIndex": 2
1785+
}
1786+
},
17601787
{
17611788
"kind": "PropertySignature",
17621789
"canonicalReference": "@google-cloud/functions-framework!Request_2#rawBody:member",
@@ -1784,6 +1811,60 @@
17841811
"startIndex": 1,
17851812
"endIndex": 2
17861813
}
1814+
},
1815+
{
1816+
"kind": "PropertySignature",
1817+
"canonicalReference": "@google-cloud/functions-framework!Request_2#spanId:member",
1818+
"docComment": "/**\n * Cloud Trace span ID.\n */\n",
1819+
"excerptTokens": [
1820+
{
1821+
"kind": "Content",
1822+
"text": "spanId?: "
1823+
},
1824+
{
1825+
"kind": "Content",
1826+
"text": "string"
1827+
},
1828+
{
1829+
"kind": "Content",
1830+
"text": ";"
1831+
}
1832+
],
1833+
"isReadonly": false,
1834+
"isOptional": true,
1835+
"releaseTag": "Public",
1836+
"name": "spanId",
1837+
"propertyTypeTokenRange": {
1838+
"startIndex": 1,
1839+
"endIndex": 2
1840+
}
1841+
},
1842+
{
1843+
"kind": "PropertySignature",
1844+
"canonicalReference": "@google-cloud/functions-framework!Request_2#traceId:member",
1845+
"docComment": "/**\n * Cloud Trace trace ID.\n */\n",
1846+
"excerptTokens": [
1847+
{
1848+
"kind": "Content",
1849+
"text": "traceId?: "
1850+
},
1851+
{
1852+
"kind": "Content",
1853+
"text": "string"
1854+
},
1855+
{
1856+
"kind": "Content",
1857+
"text": ";"
1858+
}
1859+
],
1860+
"isReadonly": false,
1861+
"isOptional": true,
1862+
"releaseTag": "Public",
1863+
"name": "traceId",
1864+
"propertyTypeTokenRange": {
1865+
"startIndex": 1,
1866+
"endIndex": 2
1867+
}
17871868
}
17881869
],
17891870
"extendsTokenRanges": [

docs/generated/api.md

+3
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,10 @@ export interface LegacyEvent {
112112

113113
// @public (undocumented)
114114
interface Request_2 extends Request_3 {
115+
executionId?: string;
115116
rawBody?: Buffer;
117+
spanId?: string;
118+
traceId?: string;
116119
}
117120
export { Request_2 as Request }
118121

package-lock.json

+7-7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
"@types/body-parser": "1.19.5",
5757
"@types/minimist": "1.2.5",
5858
"@types/mocha": "9.1.1",
59-
"@types/node": "20.11.15",
59+
"@types/node": "^20.11.24",
6060
"@types/on-finished": "2.3.4",
6161
"@types/semver": "^7.3.6",
6262
"@types/sinon": "^10.0.0",

src/async_local_storage.ts

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import * as semver from 'semver';
2+
import {Request, Response} from './functions';
3+
import {NextFunction} from 'express';
4+
import {requiredNodeJsVersionForLogExecutionID} from './options';
5+
import type {AsyncLocalStorage} from 'node:async_hooks';
6+
7+
export interface ExecutionContext {
8+
executionId?: string;
9+
traceId?: string;
10+
spanId?: string;
11+
}
12+
13+
let asyncLocalStorage: AsyncLocalStorage<ExecutionContext> | undefined;
14+
15+
export async function asyncLocalStorageMiddleware(
16+
req: Request,
17+
res: Response,
18+
next: NextFunction
19+
) {
20+
if (
21+
semver.lt(process.versions.node, requiredNodeJsVersionForLogExecutionID)
22+
) {
23+
// Skip for unsupported Node.js version.
24+
next();
25+
return;
26+
}
27+
if (!asyncLocalStorage) {
28+
const asyncHooks = await import('node:async_hooks');
29+
asyncLocalStorage = new asyncHooks.AsyncLocalStorage();
30+
}
31+
32+
asyncLocalStorage.run(
33+
{
34+
executionId: req.executionId,
35+
traceId: req.traceId,
36+
spanId: req.spanId,
37+
},
38+
() => {
39+
next();
40+
}
41+
);
42+
}
43+
44+
export function getCurrentContext(): ExecutionContext | undefined {
45+
if (!asyncLocalStorage) {
46+
return undefined;
47+
}
48+
return asyncLocalStorage.getStore();
49+
}

src/execution_context.ts

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {Request, Response} from './functions';
2+
import {NextFunction} from 'express';
3+
4+
const FUNCTION_EXECUTION_ID_HEADER_KEY = 'function-execution-id';
5+
const TRACE_CONTEXT_HEADER_KEY = 'X-Cloud-Trace-Context';
6+
7+
const TRACE_CONTEXT_PATTERN =
8+
/^(?<traceId>\w+)\/(?<spanId>\d+);o=(?<options>.+)$/;
9+
10+
function generateExecutionId() {
11+
const timestampPart = Date.now().toString(36).slice(-6);
12+
const randomPart = Math.random().toString(36).slice(-6);
13+
return timestampPart + randomPart;
14+
}
15+
16+
export const executionContextMiddleware = (
17+
req: Request,
18+
res: Response,
19+
next: NextFunction
20+
) => {
21+
let executionId = req.header(FUNCTION_EXECUTION_ID_HEADER_KEY);
22+
if (!executionId) {
23+
executionId = generateExecutionId();
24+
}
25+
req.executionId = executionId;
26+
27+
const cloudTraceContext = req.header(TRACE_CONTEXT_HEADER_KEY);
28+
if (cloudTraceContext) {
29+
const match = cloudTraceContext.match(TRACE_CONTEXT_PATTERN);
30+
if (match?.groups) {
31+
const {traceId, spanId} = match.groups;
32+
req.traceId = traceId;
33+
req.spanId = spanId;
34+
}
35+
}
36+
next();
37+
};

src/functions.ts

+12
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ export interface Request extends ExpressRequest {
3232
* A buffer which provides access to the request's raw HTTP body.
3333
*/
3434
rawBody?: Buffer;
35+
/**
36+
* Request-specified execution ID.
37+
*/
38+
executionId?: string;
39+
/**
40+
* Cloud Trace trace ID.
41+
*/
42+
traceId?: string;
43+
/**
44+
* Cloud Trace span ID.
45+
*/
46+
spanId?: string;
3547
}
3648

3749
/**

src/logger.ts

+124
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@
1414

1515
import * as express from 'express';
1616
import {FUNCTION_STATUS_HEADER_FIELD} from './types';
17+
import {getCurrentContext, ExecutionContext} from './async_local_storage';
18+
import {Buffer} from 'buffer';
19+
20+
export const EXECUTION_CONTEXT_LABELS_KEY = 'logging.googleapis.com/labels';
21+
export const EXECUTION_CONTEXT_TRACE_KEY = 'logging.googleapis.com/trace';
22+
export const EXECUTION_CONTEXT_SPAN_ID_KEY = 'logging.googleapis.com/spanId';
23+
const SEVERITY = 'severity';
1724

1825
/**
1926
* Logs an error message and sends back an error response to the incoming
@@ -60,3 +67,120 @@ export function sendCrashResponse({
6067
callback();
6168
}
6269
}
70+
71+
export function loggingHandlerAddExecutionContext() {
72+
interceptStdoutWrite();
73+
interceptStderrWrite();
74+
}
75+
76+
function interceptStdoutWrite() {
77+
const originalStdoutWrite = process.stdout.write;
78+
process.stdout.write = (data, ...args) => {
79+
const {encoding, cb} = splitArgs(args);
80+
const modifiedData = getModifiedData(data, encoding);
81+
return originalStdoutWrite.apply(process.stdout, [modifiedData, cb]);
82+
};
83+
}
84+
85+
function interceptStderrWrite() {
86+
const originalStderrWrite = process.stderr.write;
87+
process.stderr.write = (data, ...args) => {
88+
const {encoding, cb} = splitArgs(args);
89+
const modifiedData = getModifiedData(data, encoding, true);
90+
return originalStderrWrite.apply(process.stderr, [modifiedData, cb]);
91+
};
92+
}
93+
94+
export const errorHandler = (
95+
err: Error | any,
96+
req: express.Request,
97+
res: express.Response,
98+
next: express.NextFunction
99+
) => {
100+
interceptStderrWrite();
101+
res.status(500);
102+
res.render('error', {error: err});
103+
};
104+
105+
export function splitArgs(args: any[]) {
106+
let encoding, cb;
107+
if (
108+
args.length > 0 &&
109+
(Buffer.isEncoding(args[0]) || typeof args[0] === 'undefined')
110+
) {
111+
encoding = args[0];
112+
args.shift();
113+
}
114+
if (args.length > 0 && typeof args[0] === 'function') {
115+
cb = args[0];
116+
}
117+
return {encoding: encoding, cb: cb};
118+
}
119+
120+
export function getModifiedData(
121+
data: Uint8Array | string,
122+
encoding?: BufferEncoding,
123+
stderr = false
124+
) {
125+
const currentContext = getCurrentContext();
126+
if (!currentContext) {
127+
return data;
128+
}
129+
const {isJSON, processedData} = processData(data, encoding);
130+
let dataWithContext;
131+
if (isJSON) {
132+
dataWithContext = getJSONWithContext(processedData, currentContext);
133+
} else {
134+
dataWithContext = getTextWithContext(processedData, currentContext);
135+
}
136+
if (stderr) {
137+
dataWithContext[SEVERITY] = 'ERROR';
138+
}
139+
140+
return JSON.stringify(dataWithContext) + '\n';
141+
}
142+
143+
function getTextWithContext(
144+
data: Uint8Array | string,
145+
context: ExecutionContext
146+
) {
147+
return {
148+
message: data,
149+
[EXECUTION_CONTEXT_LABELS_KEY]: {execution_id: context.executionId},
150+
[EXECUTION_CONTEXT_TRACE_KEY]: context.traceId,
151+
[EXECUTION_CONTEXT_SPAN_ID_KEY]: context.spanId,
152+
};
153+
}
154+
155+
function getJSONWithContext(json: any, context: ExecutionContext) {
156+
if (EXECUTION_CONTEXT_LABELS_KEY in json) {
157+
json[EXECUTION_CONTEXT_LABELS_KEY]['execution_id'] = context.executionId;
158+
} else {
159+
json[EXECUTION_CONTEXT_LABELS_KEY] = {execution_id: context.executionId};
160+
}
161+
return {
162+
...json,
163+
[EXECUTION_CONTEXT_TRACE_KEY]: context.traceId,
164+
[EXECUTION_CONTEXT_SPAN_ID_KEY]: context.spanId,
165+
};
166+
}
167+
168+
function processData(data: Uint8Array | string, encoding?: BufferEncoding) {
169+
let decodedData;
170+
try {
171+
if (data instanceof Uint8Array) {
172+
decodedData = Buffer.from(data.buffer).toString();
173+
} else {
174+
decodedData = Buffer.from(data, encoding).toString();
175+
}
176+
} catch (e) {
177+
// Failed to decode, treat it as simple text.
178+
return {isJSON: false, processedData: data};
179+
}
180+
181+
try {
182+
return {isJSON: true, processedData: JSON.parse(decodedData)};
183+
} catch (e) {
184+
return {isJSON: false, processedData: decodedData};
185+
}
186+
}

0 commit comments

Comments
 (0)