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

Add support for Functions programming model v4 #1181

Merged
merged 4 commits into from
Sep 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
178 changes: 118 additions & 60 deletions AutoCollection/AzureFunctionsHook.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Disposable, PostInvocationContext, PreInvocationContext } from "@azure/functions-core";
import { Context, HttpRequest, HttpResponse } from "@azure/functions";
import Logging = require("../Library/Logging");
import TelemetryClient = require("../Library/TelemetryClient");
import { CorrelationContext, CorrelationContextManager } from "./CorrelationContextManager";
import * as sharedFuncTypes from "../Library/Functions";
import * as v3 from "@azure/functions";

/** Node.js Azure Functions handle incoming HTTP requests before Application Insights SDK is available,
* this code generate incoming request telemetry and generate correlation context to be used
Expand All @@ -14,27 +15,45 @@ export class AzureFunctionsHook {
private _autoGenerateIncomingRequests: boolean;
private _preInvocationHook: Disposable;
private _postInvocationHook: Disposable;
private _cachedModelHelper: FuncModelV3Helper | FuncModelV4Helper | null | undefined;

constructor(client: TelemetryClient) {
this._client = client;
this._autoGenerateIncomingRequests = false;
try {
this._functionsCoreModule = require("@azure/functions-core");
// Only v3 of Azure Functions library is supported right now. See matrix of versions here:
// https://github.com/Azure/azure-functions-nodejs-library
const funcProgModel = this._functionsCoreModule.getProgrammingModel();
if (funcProgModel.name === "@azure/functions" && funcProgModel.version.startsWith("3.")) {
this._addPreInvocationHook();
this._addPostInvocationHook();
} else {
Logging.warn(`AzureFunctionsHook does not support model "${funcProgModel.name}" version "${funcProgModel.version}"`);
}
this._addPreInvocationHook();
this._addPostInvocationHook();
}
catch (error) {
Logging.info("AzureFunctionsHook failed to load, not running in Azure Functions");
}
}

/**
* NOTE: The programming model can be changed any time until the first invocation
* For that reason, we delay setting the model helper until the first hook is called, but we can cache it after that
*/
private _getFuncModelHelper(): FuncModelV3Helper | FuncModelV4Helper | null {
if (this._cachedModelHelper === undefined) {
const funcProgModel = this._functionsCoreModule.getProgrammingModel();
if (funcProgModel.name === "@azure/functions") {
if (funcProgModel.version.startsWith("3.")) {
this._cachedModelHelper = new FuncModelV3Helper();
} else if (funcProgModel.version.startsWith("4.")) {
this._cachedModelHelper = new FuncModelV4Helper();
}
}

if (!this._cachedModelHelper) {
this._cachedModelHelper = null;
Logging.warn(`AzureFunctionsHook does not support model "${funcProgModel.name}" version "${funcProgModel.version}"`);
}
}

return this._cachedModelHelper;
}

public enable(isEnabled: boolean) {
this._autoGenerateIncomingRequests = isEnabled;
}
Expand All @@ -48,23 +67,28 @@ export class AzureFunctionsHook {
private _addPreInvocationHook() {
if (!this._preInvocationHook) {
this._preInvocationHook = this._functionsCoreModule.registerHook("preInvocation", async (preInvocationContext: PreInvocationContext) => {
const ctx: Context = <Context>preInvocationContext.invocationContext;
try {
// Start an AI Correlation Context using the provided Function context
let extractedContext = CorrelationContextManager.startOperation(ctx);
if (extractedContext) { // Will be null if CorrelationContextManager is not enabled, we should not try to propagate context in that case
extractedContext.customProperties.setProperty("InvocationId", ctx.invocationId);
if (ctx.traceContext.attributes) {
extractedContext.customProperties.setProperty("ProcessId", ctx.traceContext.attributes["ProcessId"]);
extractedContext.customProperties.setProperty("LogLevel", ctx.traceContext.attributes["LogLevel"]);
extractedContext.customProperties.setProperty("Category", ctx.traceContext.attributes["Category"]);
extractedContext.customProperties.setProperty("HostInstanceId", ctx.traceContext.attributes["HostInstanceId"]);
extractedContext.customProperties.setProperty("AzFuncLiveLogsSessionId", ctx.traceContext.attributes["#AzFuncLiveLogsSessionId"]);
}
preInvocationContext.functionCallback = CorrelationContextManager.wrapCallback(preInvocationContext.functionCallback, extractedContext);
if (this._isHttpTrigger(ctx) && this._autoGenerateIncomingRequests) {
preInvocationContext.hookData.appInsightsExtractedContext = extractedContext;
preInvocationContext.hookData.appInsightsStartTime = Date.now(); // Start trackRequest timer
const modelHelper = this._getFuncModelHelper();
if (modelHelper) {
const sharedContext = <sharedFuncTypes.Context>preInvocationContext.invocationContext;
// Start an AI Correlation Context using the provided Function context
let extractedContext = CorrelationContextManager.startOperation(sharedContext);
if (extractedContext) { // Will be null if CorrelationContextManager is not enabled, we should not try to propagate context in that case
extractedContext.customProperties.setProperty("InvocationId", sharedContext.invocationId);

const traceContext = sharedContext.traceContext;
if (traceContext.attributes) {
extractedContext.customProperties.setProperty("ProcessId", traceContext.attributes["ProcessId"]);
extractedContext.customProperties.setProperty("LogLevel", traceContext.attributes["LogLevel"]);
extractedContext.customProperties.setProperty("Category", traceContext.attributes["Category"]);
extractedContext.customProperties.setProperty("HostInstanceId", traceContext.attributes["HostInstanceId"]);
extractedContext.customProperties.setProperty("AzFuncLiveLogsSessionId", traceContext.attributes["#AzFuncLiveLogsSessionId"]);
}
preInvocationContext.functionCallback = CorrelationContextManager.wrapCallback(preInvocationContext.functionCallback, extractedContext);
if (modelHelper.isHttpTrigger(preInvocationContext) && this._autoGenerateIncomingRequests) {
preInvocationContext.hookData.appInsightsExtractedContext = extractedContext;
preInvocationContext.hookData.appInsightsStartTime = Date.now(); // Start trackRequest timer
}
}
}
}
Expand All @@ -80,21 +104,22 @@ export class AzureFunctionsHook {
if (!this._postInvocationHook) {
this._postInvocationHook = this._functionsCoreModule.registerHook("postInvocation", async (postInvocationContext: PostInvocationContext) => {
try {
if (this._autoGenerateIncomingRequests) {
const ctx: Context = <Context>postInvocationContext.invocationContext;
if (this._isHttpTrigger(ctx)) {
const request: HttpRequest = postInvocationContext.inputs[0];
if (request) {
const startTime: number = postInvocationContext.hookData.appInsightsStartTime || Date.now();
const response = this._getAzureFunctionResponse(postInvocationContext, ctx);
const extractedContext: CorrelationContext | undefined = postInvocationContext.hookData.appInsightsExtractedContext;
if (!extractedContext) {
this._createIncomingRequestTelemetry(request, response, startTime, null);
}
else {
CorrelationContextManager.runWithContext(extractedContext, () => {
this._createIncomingRequestTelemetry(request, response, startTime, extractedContext.operation.parentId);
});
const modelHelper = this._getFuncModelHelper();
if (modelHelper) {
if (this._autoGenerateIncomingRequests) {
if (modelHelper.isHttpTrigger(postInvocationContext)) {
const request = <sharedFuncTypes.HttpRequest>postInvocationContext.inputs[0];
if (request) {
const startTime: number = postInvocationContext.hookData.appInsightsStartTime || Date.now();
const extractedContext: CorrelationContext | undefined = postInvocationContext.hookData.appInsightsExtractedContext;
if (!extractedContext) {
this._createIncomingRequestTelemetry(request, postInvocationContext, startTime, null);
}
else {
CorrelationContextManager.runWithContext(extractedContext, () => {
this._createIncomingRequestTelemetry(request, postInvocationContext, startTime, extractedContext.operation.parentId);
});
}
}
}
}
Expand All @@ -107,10 +132,11 @@ export class AzureFunctionsHook {
}
}

private _createIncomingRequestTelemetry(request: HttpRequest, response: HttpResponse, startTime: number, parentId: string) {
private _createIncomingRequestTelemetry(request: sharedFuncTypes.HttpRequest, hookContext: PostInvocationContext, startTime: number, parentId: string) {
const values = this._getFuncModelHelper().getStatusCodes(hookContext);
let statusCode: string | number = 200; //Default
if (response) {
for (const value of [response.statusCode, response.status]) {
if (values) {
for (const value of values) {
if (typeof value === "number" && Number.isInteger(value)) {
statusCode = value;
break;
Expand All @@ -128,7 +154,7 @@ export class AzureFunctionsHook {
this._client.trackRequest({
name: request.method + " " + request.url,
resultCode: statusCode,
success: typeof(statusCode) === "number" ? (0 < statusCode) && (statusCode < 400) : undefined,
success: typeof (statusCode) === "number" ? (0 < statusCode) && (statusCode < 400) : undefined,
url: request.url,
time: new Date(startTime),
duration: Date.now() - startTime,
Expand All @@ -137,21 +163,6 @@ export class AzureFunctionsHook {
this._client.flush();
}

private _getAzureFunctionResponse(postInvocationContext: PostInvocationContext, ctx: Context): HttpResponse {
const httpOutputBinding = ctx.bindingDefinitions.find(b => b.direction === "out" && b.type.toLowerCase() === "http");
if (httpOutputBinding?.name === "$return") {
return postInvocationContext.result;
} else if (httpOutputBinding && ctx.bindings && ctx.bindings[httpOutputBinding.name] !== undefined) {
return ctx.bindings[httpOutputBinding.name];
} else {
return ctx.res;
}
}

private _isHttpTrigger(ctx: Context) {
return ctx.bindingDefinitions.find(b => b.type?.toLowerCase() === "httptrigger");
}

private _removeInvocationHooks() {
if (this._preInvocationHook) {
this._preInvocationHook.dispose();
Expand All @@ -163,3 +174,50 @@ export class AzureFunctionsHook {
}
}
}

class FuncModelV3Helper {
private _getInvocationContext(hookContext: PreInvocationContext | PostInvocationContext): v3.Context {
return <v3.Context>hookContext.invocationContext;
}

public getStatusCodes(hookContext: PostInvocationContext): unknown[] | undefined {
const ctx = this._getInvocationContext(hookContext);

let response: v3.HttpResponse | undefined;
const httpOutputBinding = ctx.bindingDefinitions.find(b => b.direction === "out" && b.type.toLowerCase() === "http");
if (httpOutputBinding?.name === "$return") {
response = hookContext.result;
} else if (httpOutputBinding && ctx.bindings && ctx.bindings[httpOutputBinding.name] !== undefined) {
response = ctx.bindings[httpOutputBinding.name];
} else {
response = ctx.res;
}

return response ? [response.statusCode, response.status] : undefined;
}

public isHttpTrigger(hookContext: PreInvocationContext | PostInvocationContext): boolean {
const ctx = this._getInvocationContext(hookContext);
return !!ctx.bindingDefinitions.find(b => b.type?.toLowerCase() === "httptrigger");
}
}

/**
* V4 is only supported on Node.js v18+
* Unfortunately that means we can't use the "@azure/functions" types for v4 or we break the build on Node <v18
*/
class FuncModelV4Helper {
private _getInvocationContext(hookContext: PreInvocationContext | PostInvocationContext): any {
return hookContext.invocationContext;
}

public getStatusCodes(hookContext: PostInvocationContext): unknown[] | undefined {
let response = hookContext.result;
return response ? [response.status] : undefined;
}

public isHttpTrigger(hookContext: PreInvocationContext | PostInvocationContext) {
const ctx = this._getInvocationContext(hookContext);
return ctx.options.trigger.type.toLowerCase() === "httptrigger";
}
}
18 changes: 9 additions & 9 deletions AutoCollection/CorrelationContextManager.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import events = require("events");
import Logging = require("../Library/Logging");
import * as DiagChannel from "./diagnostic-channel/initialization";
import * as azureFunctionsTypes from "../Library/Functions";
import * as sharedFuncTypes from "../Library/Functions";

// Don't reference modules from these directly. Use only for types.
import * as cls from "cls-hooked";
Expand Down Expand Up @@ -198,13 +198,13 @@ export class CorrelationContextManager {
* Create new correlation context.
*/
public static startOperation(
input: azureFunctionsTypes.Context | (http.IncomingMessage | azureFunctionsTypes.HttpRequest) | SpanContext | Span,
request?: azureFunctionsTypes.HttpRequest | string)
input: sharedFuncTypes.Context | (http.IncomingMessage | sharedFuncTypes.HttpRequest) | SpanContext | Span,
request?: sharedFuncTypes.HttpRequest | string)
: CorrelationContext | null {
const traceContext = input && (input as azureFunctionsTypes.Context).traceContext || null;
const traceContext = input && (input as sharedFuncTypes.Context).traceContext || null;
const span = input && (input as Span).spanContext ? input as Span : null;
const spanContext = input && (input as SpanContext).traceId ? input as SpanContext : null;
const headers = input && (input as http.IncomingMessage | azureFunctionsTypes.HttpRequest).headers;
const headers = input && (input as http.IncomingMessage | sharedFuncTypes.HttpRequest).headers;

// OpenTelemetry Span
if (span) {
Expand All @@ -224,7 +224,7 @@ export class CorrelationContextManager {
let tracestate = null;
operationName = traceContext.attributes["OperationName"] || operationName;
if (request) {
let azureFnRequest = request as azureFunctionsTypes.HttpRequest;
let azureFnRequest = request as sharedFuncTypes.HttpRequest;
if (azureFnRequest.headers) {
if (azureFnRequest.headers.traceparent) {
traceparent = new Traceparent(azureFnRequest.headers.traceparent);
Expand All @@ -237,10 +237,10 @@ export class CorrelationContextManager {
}
}
if (!traceparent) {
traceparent = new Traceparent(traceContext.traceparent);
traceparent = new Traceparent(traceContext.traceParent || traceContext.traceparent);
}
if (!tracestate) {
tracestate = new Tracestate(traceContext.tracestate);
tracestate = new Tracestate(traceContext.traceState || traceContext.tracestate);
}

let correlationContextHeader = undefined;
Expand All @@ -265,7 +265,7 @@ export class CorrelationContextManager {
if (headers) {
const traceparent = new Traceparent(headers.traceparent ? headers.traceparent.toString() : null);
const tracestate = new Tracestate(headers.tracestate ? headers.tracestate.toString() : null);
const parser = new HttpRequestParser(input as http.IncomingMessage | azureFunctionsTypes.HttpRequest);
const parser = new HttpRequestParser(input as http.IncomingMessage | sharedFuncTypes.HttpRequest);
const correlationContext = CorrelationContextManager.generateContextObject(
traceparent.traceId,
traceparent.parentId,
Expand Down
Loading
Loading