@genkit-ai/google-cloud
Version:
Genkit AI framework plugin for Google Cloud Platform including Firestore trace/state store and deployment helpers for Cloud Functions for Firebase.
364 lines (363 loc) • 11.9 kB
JavaScript
import {
MetricExporter
} from "@google-cloud/opentelemetry-cloud-monitoring-exporter";
import { TraceExporter } from "@google-cloud/opentelemetry-cloud-trace-exporter";
import { GcpDetectorSync } from "@google-cloud/opentelemetry-resource-util";
import { SpanStatusCode, TraceFlags } from "@opentelemetry/api";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { PinoInstrumentation } from "@opentelemetry/instrumentation-pino";
import { WinstonInstrumentation } from "@opentelemetry/instrumentation-winston";
import { Resource } from "@opentelemetry/resources";
import {
AggregationTemporality,
DefaultAggregation,
ExponentialHistogramAggregation,
InMemoryMetricExporter,
InstrumentType,
PeriodicExportingMetricReader
} from "@opentelemetry/sdk-metrics";
import {
BatchSpanProcessor,
InMemorySpanExporter
} from "@opentelemetry/sdk-trace-base";
import { GENKIT_VERSION } from "genkit";
import { logger } from "genkit/logging";
import { actionTelemetry } from "./telemetry/action.js";
import { engagementTelemetry } from "./telemetry/engagement.js";
import { featuresTelemetry } from "./telemetry/feature.js";
import { generateTelemetry } from "./telemetry/generate.js";
import { pathsTelemetry } from "./telemetry/path.js";
import {
metricsDenied,
metricsDeniedHelpText,
tracingDenied,
tracingDeniedHelpText
} from "./utils.js";
let metricExporter;
let spanProcessor;
let spanExporter;
class GcpOpenTelemetry {
config;
resource;
constructor(config) {
this.config = config;
this.resource = new Resource({ type: "global" }).merge(
new GcpDetectorSync().detect()
);
}
/**
* Log hook for writing trace and span metadata to log messages in the format
* required by GCP.
*/
gcpTraceLogHook = (span, record) => {
const spanContext = span.spanContext();
const isSampled = !!(spanContext.traceFlags & TraceFlags.SAMPLED);
const projectId = this.config.projectId;
record["logging.googleapis.com/trace"] ??= `projects/${projectId}/traces/${spanContext.traceId}`;
record["logging.googleapis.com/trace_sampled"] ??= isSampled ? "1" : "0";
record["logging.googleapis.com/spanId"] ??= spanContext.spanId;
delete record["span_id"];
delete record["trace_id"];
delete record["trace_flags"];
};
async getConfig() {
spanProcessor = new BatchSpanProcessor(await this.createSpanExporter());
return {
resource: this.resource,
spanProcessor,
sampler: this.config.sampler,
instrumentations: this.getInstrumentations(),
metricReader: await this.createMetricReader()
};
}
async createSpanExporter() {
spanExporter = new AdjustingTraceExporter(
this.shouldExportTraces() ? new TraceExporter({
// provided projectId should take precedence over env vars, etc
projectId: this.config.projectId,
// creds for non-GCP environments, in lieu of using ADC.
credentials: this.config.credentials
}) : new InMemorySpanExporter(),
this.config.exportInputAndOutput,
this.config.projectId,
getErrorHandler(
(err) => {
return tracingDenied(err);
},
await tracingDeniedHelpText()
)
);
return spanExporter;
}
/**
* Creates a {MetricReader} for pushing metrics out to GCP via OpenTelemetry.
*/
async createMetricReader() {
metricExporter = await this.buildMetricExporter();
return new PeriodicExportingMetricReader({
exportIntervalMillis: this.config.metricExportIntervalMillis,
exportTimeoutMillis: this.config.metricExportTimeoutMillis,
exporter: metricExporter
});
}
/** Gets all open telemetry instrumentations as configured by the plugin. */
getInstrumentations() {
let instrumentations = [];
if (this.config.autoInstrumentation) {
instrumentations = getNodeAutoInstrumentations(
this.config.autoInstrumentationConfig
);
}
return instrumentations.concat(this.getDefaultLoggingInstrumentations()).concat(this.config.instrumentations ?? []);
}
shouldExportTraces() {
return this.config.export && !this.config.disableTraces;
}
shouldExportMetrics() {
return this.config.export && !this.config.disableMetrics;
}
/** Always configure the Pino and Winston instrumentations */
getDefaultLoggingInstrumentations() {
return [
new WinstonInstrumentation({ logHook: this.gcpTraceLogHook }),
new PinoInstrumentation({ logHook: this.gcpTraceLogHook })
];
}
async buildMetricExporter() {
const exporter = this.shouldExportMetrics() ? new MetricExporterWrapper(
{
userAgent: {
product: "genkit",
version: GENKIT_VERSION
},
// provided projectId should take precedence over env vars, etc
projectId: this.config.projectId,
// creds for non-GCP environments, in lieu of using ADC.
credentials: this.config.credentials
},
getErrorHandler(
(err) => {
return metricsDenied(err);
},
await metricsDeniedHelpText()
)
) : new InMemoryMetricExporter(AggregationTemporality.DELTA);
return exporter;
}
}
class MetricExporterWrapper extends MetricExporter {
constructor(options, errorHandler) {
super(options);
this.errorHandler = errorHandler;
}
promise = new Promise((resolve) => resolve());
async export(metrics, resultCallback) {
await this.promise;
this.modifyStartTimes(metrics);
this.promise = new Promise((resolve) => {
super.export(metrics, (result) => {
try {
if (this.errorHandler && result.error) {
this.errorHandler(result.error);
}
resultCallback(result);
} finally {
resolve();
}
});
});
}
selectAggregation(instrumentType) {
if (instrumentType === InstrumentType.HISTOGRAM) {
return new ExponentialHistogramAggregation();
}
return new DefaultAggregation();
}
selectAggregationTemporality(instrumentType) {
return AggregationTemporality.DELTA;
}
/**
* Modify the start times of each data point to ensure no
* overlap with previous exports.
*
* Cloud metrics do not support delta metrics for custom metrics
* and will convert any DELTA aggregations to CUMULATIVE ones on
* export. There is implicit overlap in the start/end times that
* the Metric reader is sending -- the end_time of the previous
* export will become the start_time of the current export. The
* overlap in times means that only one of those records will
* persist and the other will be overwritten. This
* method adds a thousandth of a second to ensure discrete export
* timeframes.
*/
modifyStartTimes(metrics) {
metrics.scopeMetrics.forEach((scopeMetric) => {
scopeMetric.metrics.forEach((metric) => {
metric.dataPoints.forEach((dataPoint) => {
dataPoint.startTime[1] = dataPoint.startTime[1] + 1e6;
});
});
});
}
async shutdown() {
return await this.forceFlush();
}
async forceFlush() {
await this.promise;
}
}
class AdjustingTraceExporter {
constructor(exporter, logInputAndOutput, projectId, errorHandler) {
this.exporter = exporter;
this.logInputAndOutput = logInputAndOutput;
this.projectId = projectId;
this.errorHandler = errorHandler;
}
export(spans, resultCallback) {
this.exporter?.export(this.adjust(spans), (result) => {
if (this.errorHandler && result.error) {
this.errorHandler(result.error);
}
resultCallback(result);
});
}
shutdown() {
return this.exporter?.shutdown();
}
getExporter() {
return this.exporter;
}
forceFlush() {
if (this.exporter?.forceFlush) {
return this.exporter.forceFlush();
}
return Promise.resolve();
}
adjust(spans) {
return spans.map((span) => {
this.tickTelemetry(span);
span = this.redactInputOutput(span);
span = this.markErrorSpanAsError(span);
span = this.markFailedSpan(span);
span = this.markGenkitFeature(span);
span = this.markGenkitModel(span);
span = this.normalizeLabels(span);
return span;
});
}
tickTelemetry(span) {
const attributes = span.attributes;
if (!Object.keys(attributes).includes("genkit:type")) {
return;
}
const type = attributes["genkit:type"];
const subtype = attributes["genkit:metadata:subtype"];
const isRoot = !!span.attributes["genkit:isRoot"];
pathsTelemetry.tick(span, this.logInputAndOutput, this.projectId);
if (isRoot) {
featuresTelemetry.tick(span, this.logInputAndOutput, this.projectId);
span.attributes["genkit:rootState"] = span.attributes["genkit:state"];
} else {
if (type === "action" && subtype === "model") {
generateTelemetry.tick(span, this.logInputAndOutput, this.projectId);
}
if (type === "action" && subtype === "tool") {
}
if (type === "action" || type === "flow" || type == "flowStep" || type == "util") {
actionTelemetry.tick(span, this.logInputAndOutput, this.projectId);
}
}
if (type === "userEngagement") {
engagementTelemetry.tick(span, this.logInputAndOutput, this.projectId);
}
}
redactInputOutput(span) {
const hasInput = "genkit:input" in span.attributes;
const hasOutput = "genkit:output" in span.attributes;
return !hasInput && !hasOutput ? span : {
...span,
spanContext: span.spanContext,
attributes: {
...span.attributes,
"genkit:input": "<redacted>",
"genkit:output": "<redacted>"
}
};
}
// This is a workaround for GCP Trace to mark a span with a red
// exclamation mark indicating that it is an error.
markErrorSpanAsError(span) {
return span.status.code !== SpanStatusCode.ERROR ? span : {
...span,
spanContext: span.spanContext,
attributes: {
...span.attributes,
"/http/status_code": "599"
}
};
}
normalizeLabels(span) {
const normalized = {};
for (const [key, value] of Object.entries(span.attributes)) {
normalized[key.replace(/\:/g, "/")] = value;
}
return {
...span,
spanContext: span.spanContext,
attributes: normalized
};
}
markFailedSpan(span) {
if (span.attributes["genkit:isFailureSource"]) {
span.attributes["genkit:failedSpan"] = span.attributes["genkit:name"];
span.attributes["genkit:failedPath"] = span.attributes["genkit:path"];
}
return span;
}
markGenkitFeature(span) {
if (span.attributes["genkit:isRoot"] && !!span.attributes["genkit:name"]) {
span.attributes["genkit:feature"] = span.attributes["genkit:name"];
}
return span;
}
markGenkitModel(span) {
if (span.attributes["genkit:metadata:subtype"] === "model" && !!span.attributes["genkit:name"]) {
span.attributes["genkit:model"] = span.attributes["genkit:name"];
}
return span;
}
}
function getErrorHandler(shouldLogFn, helpText) {
let instructionsLogged = false;
return (err) => {
const defaultLogger = logger.defaultLogger;
if (err && shouldLogFn(err)) {
if (!instructionsLogged) {
instructionsLogged = true;
defaultLogger.error(
`Unable to send telemetry to Google Cloud: ${err.message}
${helpText}
`
);
}
} else if (err) {
defaultLogger.error(`Unable to send telemetry to Google Cloud: ${err}`);
}
};
}
function __getMetricExporterForTesting() {
return metricExporter;
}
function __getSpanExporterForTesting() {
return spanExporter.getExporter();
}
function __forceFlushSpansForTesting() {
spanProcessor.forceFlush();
}
export {
GcpOpenTelemetry,
__forceFlushSpansForTesting,
__getMetricExporterForTesting,
__getSpanExporterForTesting
};
//# sourceMappingURL=gcpOpenTelemetry.mjs.map