Skip to content
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

feat: add execution id support #592

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3d279d7
feat: add execution id support.
liuyunnnn Feb 22, 2024
f7f3a49
add --enable-execution-id.
liuyunnnn Feb 22, 2024
29fc127
Force execution id support on nodejs version to be at least 12.17.0.
liuyunnnn Feb 22, 2024
c2a204b
import async_hooks dynamically.
liuyunnnn Feb 23, 2024
bf56546
revert package-lock.
liuyunnnn Feb 23, 2024
106b0f9
Fix test and dependency.
liuyunnnn Feb 23, 2024
dfdecb2
fix test.
liuyunnnn Feb 23, 2024
da044b2
Fix latest support node js version.
liuyunnnn Feb 23, 2024
15e2384
get rid of text-encoding.
liuyunnnn Feb 23, 2024
998e393
Addressed commets.
liuyunnnn Feb 26, 2024
a898052
Fixed format.
liuyunnnn Feb 26, 2024
a212010
Add method getExecutionId() to allow users get execution id.
liuyunnnn Feb 27, 2024
37eebd7
Control enabling or not by env var LOG_EXECUTION_ID.
liuyunnnn Feb 27, 2024
79270d5
Error handler to catch all unhandled exceptions and log with executio…
liuyunnnn Feb 27, 2024
58871f0
Update doc
liuyunnnn Feb 27, 2024
19b0de7
Addressed comments
liuyunnnn Feb 28, 2024
a01bd34
fixed format.
liuyunnnn Feb 28, 2024
ad41e7b
Addressed comments.
liuyunnnn Mar 4, 2024
2ae9bf8
Updated dependencies to use node:crypto
liuyunnnn Mar 4, 2024
5fc4025
Update dependencies to use node:crypto
liuyunnnn Mar 4, 2024
c72c7b6
fixed tsconfig
liuyunnnn Mar 4, 2024
12d1c5b
fixed dependencies.
liuyunnnn Mar 4, 2024
1ab7659
Seperate execution context middleware into two and add execution id, …
liuyunnnn Mar 4, 2024
859a7fe
Update docs
liuyunnnn Mar 4, 2024
d18088d
delete getExecutionId()
liuyunnnn Mar 4, 2024
19a3a4c
cleanup getExecutionId().
liuyunnnn Mar 4, 2024
363b4d4
add getExecutionId() back as req.executionId is not accessible to eve…
liuyunnnn Mar 5, 2024
409c5c9
Update documents.
liuyunnnn Mar 5, 2024
ea14efc
addressed comments and remove getExecutionId().
liuyunnnn Mar 6, 2024
fcb6a40
import type AsyncLocalStorage
liuyunnnn Mar 6, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ ignored.
| `--target` | `FUNCTION_TARGET` | The name of the exported function to be invoked in response to requests. Default: `function` |
| `--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` |
| `--source` | `FUNCTION_SOURCE` | The path to the directory of your function. Default: `cwd` (the current working directory) |
| `--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. |

You can set command-line flags in your `package.json` via the `start` script.
For example:
Expand Down
81 changes: 81 additions & 0 deletions docs/generated/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -1757,6 +1757,33 @@
"name": "Request_2",
"preserveMemberOrder": false,
"members": [
{
"kind": "PropertySignature",
"canonicalReference": "@google-cloud/functions-framework!Request_2#executionId:member",
"docComment": "/**\n * Request-specified execution ID.\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "executionId?: "
},
{
"kind": "Content",
"text": "string"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": true,
"releaseTag": "Public",
"name": "executionId",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "@google-cloud/functions-framework!Request_2#rawBody:member",
Expand Down Expand Up @@ -1784,6 +1811,60 @@
"startIndex": 1,
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "@google-cloud/functions-framework!Request_2#spanId:member",
"docComment": "/**\n * Cloud Trace span ID.\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "spanId?: "
},
{
"kind": "Content",
"text": "string"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": true,
"releaseTag": "Public",
"name": "spanId",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "@google-cloud/functions-framework!Request_2#traceId:member",
"docComment": "/**\n * Cloud Trace trace ID.\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "traceId?: "
},
{
"kind": "Content",
"text": "string"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": true,
"releaseTag": "Public",
"name": "traceId",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
}
],
"extendsTokenRanges": [
Expand Down
3 changes: 3 additions & 0 deletions docs/generated/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,10 @@ export interface LegacyEvent {

// @public (undocumented)
interface Request_2 extends Request_3 {
executionId?: string;
rawBody?: Buffer;
spanId?: string;
traceId?: string;
}
export { Request_2 as Request }

Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"@types/body-parser": "1.19.5",
"@types/minimist": "1.2.5",
"@types/mocha": "9.1.1",
"@types/node": "20.11.15",
"@types/node": "^20.11.24",
"@types/on-finished": "2.3.4",
"@types/semver": "^7.3.6",
"@types/sinon": "^10.0.0",
Expand Down
49 changes: 49 additions & 0 deletions src/async_local_storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as semver from 'semver';
import {Request, Response} from './functions';
import {NextFunction} from 'express';
import {requiredNodeJsVersionForLogExecutionID} from './options';
import type {AsyncLocalStorage} from 'node:async_hooks';

export interface ExecutionContext {
executionId?: string;
traceId?: string;
spanId?: string;
}

let asyncLocalStorage: AsyncLocalStorage<ExecutionContext> | undefined;

export async function asyncLocalStorageMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
if (
semver.lt(process.versions.node, requiredNodeJsVersionForLogExecutionID)
) {
// Skip for unsupported Node.js version.
next();
return;
}
if (!asyncLocalStorage) {
const asyncHooks = await import('node:async_hooks');
asyncLocalStorage = new asyncHooks.AsyncLocalStorage();
}

asyncLocalStorage.run(
{
executionId: req.executionId,
traceId: req.traceId,
spanId: req.spanId,
},
() => {
next();
}
);
}

export function getCurrentContext(): ExecutionContext | undefined {
if (!asyncLocalStorage) {
return undefined;
}
return asyncLocalStorage.getStore();
}
37 changes: 37 additions & 0 deletions src/execution_context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {Request, Response} from './functions';
import {NextFunction} from 'express';

const FUNCTION_EXECUTION_ID_HEADER_KEY = 'function-execution-id';
const TRACE_CONTEXT_HEADER_KEY = 'X-Cloud-Trace-Context';

const TRACE_CONTEXT_PATTERN =
/^(?<traceId>\w+)\/(?<spanId>\d+);o=(?<options>.+)$/;

function generateExecutionId() {
const timestampPart = Date.now().toString(36).slice(-6);
const randomPart = Math.random().toString(36).slice(-6);
return timestampPart + randomPart;
liuyunnnn marked this conversation as resolved.
Show resolved Hide resolved
}

export const executionContextMiddleware = (
req: Request,
res: Response,
next: NextFunction
) => {
let executionId = req.header(FUNCTION_EXECUTION_ID_HEADER_KEY);
if (!executionId) {
executionId = generateExecutionId();
}
req.executionId = executionId;

const cloudTraceContext = req.header(TRACE_CONTEXT_HEADER_KEY);
if (cloudTraceContext) {
const match = cloudTraceContext.match(TRACE_CONTEXT_PATTERN);
if (match?.groups) {
const {traceId, spanId} = match.groups;
req.traceId = traceId;
req.spanId = spanId;
}
}
next();
};
12 changes: 12 additions & 0 deletions src/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ export interface Request extends ExpressRequest {
* A buffer which provides access to the request's raw HTTP body.
*/
rawBody?: Buffer;
/**
* Request-specified execution ID.
*/
executionId?: string;
/**
* Cloud Trace trace ID.
*/
traceId?: string;
/**
* Cloud Trace span ID.
*/
spanId?: string;
}

/**
Expand Down
124 changes: 124 additions & 0 deletions src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@

import * as express from 'express';
import {FUNCTION_STATUS_HEADER_FIELD} from './types';
import {getCurrentContext, ExecutionContext} from './async_local_storage';
import {Buffer} from 'buffer';

export const EXECUTION_CONTEXT_LABELS_KEY = 'logging.googleapis.com/labels';
export const EXECUTION_CONTEXT_TRACE_KEY = 'logging.googleapis.com/trace';
export const EXECUTION_CONTEXT_SPAN_ID_KEY = 'logging.googleapis.com/spanId';
const SEVERITY = 'severity';

/**
* Logs an error message and sends back an error response to the incoming
Expand Down Expand Up @@ -60,3 +67,120 @@
callback();
}
}

export function loggingHandlerAddExecutionContext() {
liuyunnnn marked this conversation as resolved.
Show resolved Hide resolved
interceptStdoutWrite();
liuyunnnn marked this conversation as resolved.
Show resolved Hide resolved
interceptStderrWrite();
}

function interceptStdoutWrite() {
const originalStdoutWrite = process.stdout.write;
process.stdout.write = (data, ...args) => {
const {encoding, cb} = splitArgs(args);
const modifiedData = getModifiedData(data, encoding);
return originalStdoutWrite.apply(process.stdout, [modifiedData, cb]);
};
}

function interceptStderrWrite() {
const originalStderrWrite = process.stderr.write;
process.stderr.write = (data, ...args) => {
const {encoding, cb} = splitArgs(args);
const modifiedData = getModifiedData(data, encoding, true);
return originalStderrWrite.apply(process.stderr, [modifiedData, cb]);
};
}

export const errorHandler = (
err: Error | any,

Check warning on line 95 in src/logger.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
req: express.Request,
res: express.Response,
next: express.NextFunction

Check warning on line 98 in src/logger.ts

View workflow job for this annotation

GitHub Actions / lint

'next' is defined but never used
) => {
interceptStderrWrite();
res.status(500);
res.render('error', {error: err});
};

export function splitArgs(args: any[]) {

Check warning on line 105 in src/logger.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
let encoding, cb;
if (
args.length > 0 &&
(Buffer.isEncoding(args[0]) || typeof args[0] === 'undefined')
) {
encoding = args[0];
args.shift();
}
if (args.length > 0 && typeof args[0] === 'function') {
cb = args[0];
}
return {encoding: encoding, cb: cb};
}

export function getModifiedData(
liuyunnnn marked this conversation as resolved.
Show resolved Hide resolved
data: Uint8Array | string,
encoding?: BufferEncoding,
stderr = false
) {
const currentContext = getCurrentContext();
if (!currentContext) {
return data;
}
const {isJSON, processedData} = processData(data, encoding);
let dataWithContext;
if (isJSON) {
dataWithContext = getJSONWithContext(processedData, currentContext);
} else {
dataWithContext = getTextWithContext(processedData, currentContext);
}
if (stderr) {
dataWithContext[SEVERITY] = 'ERROR';
}

return JSON.stringify(dataWithContext) + '\n';
}

function getTextWithContext(
data: Uint8Array | string,
context: ExecutionContext
) {
return {
message: data,
[EXECUTION_CONTEXT_LABELS_KEY]: {execution_id: context.executionId},
[EXECUTION_CONTEXT_TRACE_KEY]: context.traceId,
[EXECUTION_CONTEXT_SPAN_ID_KEY]: context.spanId,
};
}

function getJSONWithContext(json: any, context: ExecutionContext) {

Check warning on line 155 in src/logger.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
if (EXECUTION_CONTEXT_LABELS_KEY in json) {
json[EXECUTION_CONTEXT_LABELS_KEY]['execution_id'] = context.executionId;
} else {
json[EXECUTION_CONTEXT_LABELS_KEY] = {execution_id: context.executionId};
}
return {
...json,
[EXECUTION_CONTEXT_TRACE_KEY]: context.traceId,
[EXECUTION_CONTEXT_SPAN_ID_KEY]: context.spanId,
};
}

function processData(data: Uint8Array | string, encoding?: BufferEncoding) {
let decodedData;
try {
if (data instanceof Uint8Array) {
decodedData = Buffer.from(data.buffer).toString();
} else {
decodedData = Buffer.from(data, encoding).toString();
}
} catch (e) {
// Failed to decode, treat it as simple text.
return {isJSON: false, processedData: data};
}

try {
return {isJSON: true, processedData: JSON.parse(decodedData)};
} catch (e) {
return {isJSON: false, processedData: decodedData};
}
}
Loading
Loading