diff --git a/packages/autometrics/src/buildInfo.ts b/packages/autometrics/src/buildInfo.ts index ad76c0c..8b4359c 100644 --- a/packages/autometrics/src/buildInfo.ts +++ b/packages/autometrics/src/buildInfo.ts @@ -1,9 +1,25 @@ import type { UpDownCounter } from "$otel/api"; -import { BUILD_INFO_DESCRIPTION, BUILD_INFO_NAME } from "./constants.ts"; +import { + AUTOMETRICS_VERSION_LABEL, + BRANCH_LABEL, + BUILD_INFO_DESCRIPTION, + BUILD_INFO_NAME, + COMMIT_LABEL, + REPOSITORY_PROVIDER_LABEL, + REPOSITORY_URL_LABEL, + VERSION_LABEL, +} from "./constants.ts"; import { getMeter } from "./instrumentation.ts"; import { debug } from "./logger.ts"; -import { getBranch, getCommit, getVersion } from "./platform.deno.ts"; +import { + getBranch, + getCommit, + getRepositoryProvider, + getRepositoryUrl, + getVersion, +} from "./platform.deno.ts"; +import { detectRepositoryProvider } from "./utils.ts"; /** * BuildInfo is used to create the `build_info` metric that helps to identify @@ -14,12 +30,20 @@ import { getBranch, getCommit, getVersion } from "./platform.deno.ts"; */ export type BuildInfo = { /** - * The current version of the application. + * The version of the Autometrics specification supported by this library. * - * Should be set through the `AUTOMETRICS_VERSION` environment variable, or by + * This is set automatically by `autometrics-ts` and you should not override + * this unless you know what you are doing. + */ + [AUTOMETRICS_VERSION_LABEL]?: string; + + /** + * The current commit hash of the application. + * + * Should be set through the `AUTOMETRICS_BRANCH` environment variable, or by * explicitly specifying the `buildInfo` when calling `init()`. */ - version?: string; + [BRANCH_LABEL]?: string; /** * The current commit hash of the application. @@ -27,15 +51,25 @@ export type BuildInfo = { * Should be set through the `AUTOMETRICS_COMMIT` environment variable, or by * explicitly specifying the `buildInfo` when calling `init()`. */ - commit?: string; + [COMMIT_LABEL]?: string; /** - * The current commit hash of the application. + * The URL to the repository where the project's source code is located. + */ + [REPOSITORY_URL_LABEL]?: string; + + /** + * A hint as to which provider is being used to host the repository. + */ + [REPOSITORY_PROVIDER_LABEL]?: string; + + /** + * The current version of the application. * - * Should be set through the `AUTOMETRICS_BRANCH` environment variable, or by + * Should be set through the `AUTOMETRICS_VERSION` environment variable, or by * explicitly specifying the `buildInfo` when calling `init()`. */ - branch?: string; + [VERSION_LABEL]?: string; /** * The "clearmode" label of the `build_info` metric. @@ -56,7 +90,15 @@ export type BuildInfo = { * * @internal */ -const buildInfo: BuildInfo = {}; +const buildInfo: BuildInfo = { + [AUTOMETRICS_VERSION_LABEL]: "1.0.0", + [BRANCH_LABEL]: "", + [COMMIT_LABEL]: "", + [REPOSITORY_PROVIDER_LABEL]: "", + [REPOSITORY_URL_LABEL]: "", + [VERSION_LABEL]: "", + clearmode: "", +}; let buildInfoGauge: UpDownCounter; @@ -69,10 +111,22 @@ let buildInfoGauge: UpDownCounter; export function recordBuildInfo(info: BuildInfo) { debug("Recording build info"); - buildInfo.version = info.version ?? ""; - buildInfo.commit = info.commit ?? ""; - buildInfo.branch = info.branch ?? ""; - buildInfo.clearmode = info.clearmode ?? ""; + for (const key of Object.keys(buildInfo)) { + const labelName = key as keyof BuildInfo; + const labelValue = info[labelName]; + if (typeof labelValue === "string") { + (buildInfo[labelName] as string) = labelValue; + } + } + + if ( + info[REPOSITORY_URL_LABEL] && + info[REPOSITORY_PROVIDER_LABEL] === undefined + ) { + buildInfo[REPOSITORY_PROVIDER_LABEL] = detectRepositoryProvider( + info[REPOSITORY_URL_LABEL], + ); + } if (!buildInfoGauge) { buildInfoGauge = getMeter().createUpDownCounter(BUILD_INFO_NAME, { @@ -88,8 +142,10 @@ export function recordBuildInfo(info: BuildInfo) { */ export function createDefaultBuildInfo(): BuildInfo { return { - version: getVersion(), - commit: getCommit(), - branch: getBranch(), + [VERSION_LABEL]: getVersion(), + [COMMIT_LABEL]: getCommit(), + [BRANCH_LABEL]: getBranch(), + [REPOSITORY_URL_LABEL]: getRepositoryUrl(), + [REPOSITORY_PROVIDER_LABEL]: getRepositoryProvider(), }; } diff --git a/packages/autometrics/src/constants.ts b/packages/autometrics/src/constants.ts index ff7493b..2422cf4 100644 --- a/packages/autometrics/src/constants.ts +++ b/packages/autometrics/src/constants.ts @@ -1,9 +1,30 @@ +// Spec version +export const AUTOMETRICS_VERSION = "1.0.0"; + // Metrics export const COUNTER_NAME = "function.calls" as const; export const HISTOGRAM_NAME = "function.calls.duration" as const; export const GAUGE_NAME = "function.calls.concurrent" as const; export const BUILD_INFO_NAME = "build_info" as const; +// Labels +export const AUTOMETRICS_VERSION_LABEL = "autometrics_version" as const; +export const BRANCH_LABEL = "branch" as const; +export const CALLER_FUNCTION_LABEL = "caller_function" as const; +export const CALLER_MODULE_LABEL = "caller_module" as const; +export const COMMIT_LABEL = "commit" as const; +export const FUNCTION_LABEL = "function" as const; +export const MODULE_LABEL = "module" as const; +export const OBJECTIVE_NAME_LABEL = "objective_name" as const; +export const OBJECTIVE_PERCENTILE_LABEL = "objective_percentile" as const; +export const OBJECTIVE_LATENCY_THRESHOLD_LABEL = + "objective_latency_threshold" as const; +export const REPOSITORY_URL_LABEL = "repository_url" as const; +export const REPOSITORY_PROVIDER_LABEL = "repository_provider" as const; +export const RESULT_LABEL = "result" as const; +export const SERVICE_NAME_LABEL = "service_name" as const; +export const VERSION_LABEL = "version" as const; + // Descriptions export const COUNTER_DESCRIPTION = "Autometrics counter for tracking function calls" as const; diff --git a/packages/autometrics/src/objectives.ts b/packages/autometrics/src/objectives.ts index 00bb2cd..f85a5be 100644 --- a/packages/autometrics/src/objectives.ts +++ b/packages/autometrics/src/objectives.ts @@ -1,3 +1,11 @@ +import type { Attributes } from "npm:@opentelemetry/api@^1.6.0"; + +import { + OBJECTIVE_LATENCY_THRESHOLD_LABEL, + OBJECTIVE_NAME_LABEL, + OBJECTIVE_PERCENTILE_LABEL, +} from "./constants.ts"; + /** * This represents a Service-Level Objective (SLO) for a function or group of functions. * The objective should be given a descriptive name and can represent @@ -128,3 +136,45 @@ export enum ObjectiveLatency { */ Ms10000 = "10", } + +export function getObjectiveAttributes(objective: Objective | undefined): { + counterObjectiveAttributes: Attributes; + histogramObjectiveAttributes: Attributes; +} { + // NOTE - Gravel Gateway will reject two metrics of the same name if one of + // them has a subset of the attributes of the other. This means to be + // able to support functions that have objectives, as well as functions + // that do not have objectives, we need to default to setting the + // labels to empty strings. + const counterObjectiveAttributes: Attributes = { + [OBJECTIVE_NAME_LABEL]: "", + [OBJECTIVE_PERCENTILE_LABEL]: "", + }; + + const histogramObjectiveAttributes: Attributes = { + [OBJECTIVE_NAME_LABEL]: "", + [OBJECTIVE_LATENCY_THRESHOLD_LABEL]: "", + [OBJECTIVE_PERCENTILE_LABEL]: "", + }; + + if (objective) { + const { latency, name, successRate } = objective; + + counterObjectiveAttributes[OBJECTIVE_NAME_LABEL] = name; + histogramObjectiveAttributes[OBJECTIVE_NAME_LABEL] = name; + + if (latency) { + const [threshold, latencyPercentile] = latency; + histogramObjectiveAttributes[OBJECTIVE_LATENCY_THRESHOLD_LABEL] = + threshold; + histogramObjectiveAttributes[OBJECTIVE_PERCENTILE_LABEL] = + latencyPercentile; + } + + if (successRate) { + counterObjectiveAttributes[OBJECTIVE_PERCENTILE_LABEL] = successRate; + } + } + + return { counterObjectiveAttributes, histogramObjectiveAttributes }; +} diff --git a/packages/autometrics/src/platform.deno.ts b/packages/autometrics/src/platform.deno.ts index ea22530..107902a 100644 --- a/packages/autometrics/src/platform.deno.ts +++ b/packages/autometrics/src/platform.deno.ts @@ -1,4 +1,5 @@ import { AsyncLocalStorage } from "node:async_hooks"; +import { getGitRepositoryUrl } from "./platformUtils.ts"; /** * Returns the version of the application, based on environment variables. @@ -38,6 +39,24 @@ export function getCwd(): string { return Deno.cwd(); } +/** + * Returns the URL to the repository where the project's source code is located. + * + * @internal + */ +export function getRepositoryUrl(): string | undefined { + return Deno.env.get("AUTOMETRICS_REPOSITORY_URL") ?? detectRepositoryUrl(); +} + +/** + * Returns a hint as to which provider is being used to host the repository. + * + * @internal + */ +export function getRepositoryProvider(): string | undefined { + return Deno.env.get("AUTOMETRICS_REPOSITORY_PROVIDER"); +} + /** * Caller information we track across async function calls. * @@ -57,3 +76,10 @@ export type AsyncContext = { callerFunction?: string; callerModule?: string }; export function getALSInstance(): AsyncLocalStorage | undefined { return new AsyncLocalStorage(); } + +function detectRepositoryUrl(): string | undefined { + try { + const gitConfig = Deno.readFileSync(".git/config"); + return getGitRepositoryUrl(gitConfig); + } catch {} +} diff --git a/packages/autometrics/src/platform.node.ts b/packages/autometrics/src/platform.node.ts index 5b026bb..72370a8 100644 --- a/packages/autometrics/src/platform.node.ts +++ b/packages/autometrics/src/platform.node.ts @@ -3,6 +3,9 @@ // @ts-ignore statements in this file... import { AsyncLocalStorage } from "node:async_hooks"; +import { readFileSync } from "node:fs"; + +import { getGitRepositoryUrl } from "./platformUtils.ts"; /** * Returns the version of the application, based on environment variables. @@ -52,6 +55,26 @@ export function getCwd(): string { return process.cwd(); } +/** + * Returns the URL to the repository where the project's source code is located. + * + * @internal + */ +export function getRepositoryUrl(): string | undefined { + // @ts-ignore + return process.env.AUTOMETRICS_REPOSITORY_URL ?? detectRepositoryUrl(); +} + +/** + * Returns a hint as to which provider is being used to host the repository. + * + * @internal + */ +export function getRepositoryProvider(): string | undefined { + // @ts-ignore + return process.env.AUTOMETRICS_REPOSITORY_PROVIDER; +} + /** * Returns a new `AsyncLocalStorage` instance for storing caller information. * @@ -63,3 +86,10 @@ export function getALSInstance() { callerModule?: string; }>(); } + +function detectRepositoryUrl(): string | undefined { + try { + const gitConfig = readFileSync(".git/config"); + return getGitRepositoryUrl(gitConfig); + } catch {} +} diff --git a/packages/autometrics/src/platform.web.ts b/packages/autometrics/src/platform.web.ts index f4d1315..b9f120e 100644 --- a/packages/autometrics/src/platform.web.ts +++ b/packages/autometrics/src/platform.web.ts @@ -1,29 +1,23 @@ /** - * On web, environment variables don't exist, so we return an empty string. + * On web, environment variables don't exist, so we don't return anything. * * @internal */ -export function getVersion(): string | undefined { - return ""; -} +export function getVersion() {} /** - * On web, environment variables don't exist, so we return an empty string. + * On web, environment variables don't exist, so we don't return anything. * * @internal */ -export function getCommit(): string | undefined { - return ""; -} +export function getCommit() {} /** - * On web, environment variables don't exist, so we return an empty string. + * On web, environment variables don't exist, so we don't return anything. * * @internal */ -export function getBranch(): string | undefined { - return ""; -} +export function getBranch() {} /** * On web, there's no concept of a working directory, so we return an empty @@ -35,6 +29,20 @@ export function getCwd(): string { return ""; } +/** + * On web, environment variables don't exist, so we don't return anything. + * + * @internal + */ +export function getRepositoryUrl() {} + +/** + * On web, environment variables don't exist, so we don't return anything. + * + * @internal + */ +export function getRepositoryProvider() {} + /** * `AsyncLocalStorage` is not supported on web, so we don't return anything. * diff --git a/packages/autometrics/src/platformUtils.ts b/packages/autometrics/src/platformUtils.ts new file mode 100644 index 0000000..a1ebde4 --- /dev/null +++ b/packages/autometrics/src/platformUtils.ts @@ -0,0 +1,64 @@ +/** + * Parses the `.git/config` file to extract the repository URL. + * + * For documentation of the file format, see: https://git-scm.com/docs/git-config#_configuration_file + */ +export function getGitRepositoryUrl(gitConfig: Uint8Array): string | undefined { + const lines = new TextDecoder("utf8").decode(gitConfig).split("\n"); + + let section = ""; + let subsection = ""; + for (const line of lines) { + const trimmedLine = line.trim(); + + // Skip empty lines and comments: + if ( + trimmedLine.length === 0 || + trimmedLine.startsWith("#") || + trimmedLine.startsWith(";") + ) { + continue; + } + + // Detect section start: + if (trimmedLine.startsWith("[") && trimmedLine.endsWith("]")) { + const firstQuoteIndex = trimmedLine.indexOf('"', 3); + const lastQuoteIndex = + firstQuoteIndex === -1 ? -1 : trimmedLine.lastIndexOf('"'); + + section = trimmedLine.slice(1, firstQuoteIndex).trim().toLowerCase(); + subsection = + lastQuoteIndex > firstQuoteIndex + ? unquote(trimmedLine.slice(firstQuoteIndex, lastQuoteIndex + 1)) + : ""; + continue; + } + + // This is the only section we care about: + if (section !== "remote" || subsection !== "origin") { + continue; + } + + const equalIndex = trimmedLine.indexOf("="); + if (equalIndex === -1) { + continue; + } + + // Look for the URL declaration: + const name = trimmedLine.slice(0, equalIndex).trim().toLowerCase(); + if (name === "url") { + return unquote(trimmedLine.slice(equalIndex + 1).trim()); + } + } +} + +function unquote(maybeQuotedValue: string): string { + if (maybeQuotedValue.startsWith('"') && maybeQuotedValue.endsWith('"')) { + return maybeQuotedValue + .slice(1, -1) + .replace(/\\"/g, '"') + .replace(/\\\\/g, "\\"); + } + + return maybeQuotedValue; +} diff --git a/packages/autometrics/src/utils.ts b/packages/autometrics/src/utils.ts index 8e79e90..52c346b 100644 --- a/packages/autometrics/src/utils.ts +++ b/packages/autometrics/src/utils.ts @@ -1,5 +1,29 @@ import { getCwd } from "./platform.deno.ts"; +/** + * Attempts to auto-detect the repository provider if none is specified, but we + * do know the repository URL. + */ +export function detectRepositoryProvider( + repositoryUrl: string | undefined, +): string | undefined { + if (!repositoryUrl) { + return; + } + + if (repositoryUrl.includes("github.com")) { + return "github"; + } + + if (repositoryUrl.includes("gitlab.com")) { + return "gitlab"; + } + + if (repositoryUrl.includes("bitbucket.org")) { + return "bitbucket"; + } +} + // HACK: this entire function is a hacky way to acquire the module name for a // given function e.g.: dist/index.js export function getModulePath(): string | undefined { diff --git a/packages/autometrics/src/wrappers.ts b/packages/autometrics/src/wrappers.ts index 8bd9245..0bd5c1e 100644 --- a/packages/autometrics/src/wrappers.ts +++ b/packages/autometrics/src/wrappers.ts @@ -1,18 +1,23 @@ -import { Attributes, ValueType } from "$otel/api"; +import { ValueType } from "npm:@opentelemetry/api@^1.6.0"; import { + CALLER_FUNCTION_LABEL, + CALLER_MODULE_LABEL, COUNTER_DESCRIPTION, COUNTER_NAME, + FUNCTION_LABEL, GAUGE_DESCRIPTION, GAUGE_NAME, HISTOGRAM_DESCRIPTION, HISTOGRAM_NAME, + MODULE_LABEL, + RESULT_LABEL, } from "./constants.ts"; -import { getMeter, metricsRecorded } from "./instrumentation.ts"; -import { trace, warn } from "./logger.ts"; -import type { Objective } from "./objectives.ts"; import { getALSInstance } from "./platform.deno.ts"; +import { getMeter, metricsRecorded } from "./instrumentation.ts"; import { getModulePath, isFunction, isObject, isPromise } from "./utils.ts"; +import { getObjectiveAttributes, Objective } from "./objectives.ts"; +import { trace, warn } from "./logger.ts"; const asyncLocalStorage = getALSInstance(); @@ -230,38 +235,8 @@ export function autometrics( return fn as F; } - // NOTE - Gravel Gateway will reject two metrics of the same name if one of - // them has a subset of the attributes of the other. This means to be - // able to support functions that have objectives, as well as functions - // that do not have objectives, we need to default to setting the objective_* - // labels to the empty string. - const counterObjectiveAttributes: Attributes = { - objective_name: "", - objective_percentile: "", - }; - - const histogramObjectiveAttributes: Attributes = { - objective_name: "", - objective_latency_threshold: "", - objective_percentile: "", - }; - - if (objective) { - const { latency, name, successRate } = objective; - - counterObjectiveAttributes.objective_name = name; - histogramObjectiveAttributes.objective_name = name; - - if (latency) { - const [threshold, latencyPercentile] = latency; - histogramObjectiveAttributes.objective_latency_threshold = threshold; - histogramObjectiveAttributes.objective_percentile = latencyPercentile; - } - - if (successRate) { - counterObjectiveAttributes.objective_percentile = successRate; - } - } + const { counterObjectiveAttributes, histogramObjectiveAttributes } = + getObjectiveAttributes(objective); const meter = getMeter(); const counter = meter.createCounter(COUNTER_NAME, { @@ -280,19 +255,19 @@ export function autometrics( : null; counter.add(0, { - function: functionName, - module: moduleName, - result: "ok", - caller_function: "", - caller_module: "", + [FUNCTION_LABEL]: functionName, + [MODULE_LABEL]: moduleName, + [RESULT_LABEL]: "ok", + [CALLER_FUNCTION_LABEL]: "", + [CALLER_MODULE_LABEL]: "", ...counterObjectiveAttributes, }); return (...params) => { const autometricsStart = performance.now(); concurrencyGauge?.add(1, { - function: functionName, - module: moduleName, + [FUNCTION_LABEL]: functionName, + [MODULE_LABEL]: moduleName, }); const callerData = asyncLocalStorage?.getStore(); @@ -303,11 +278,11 @@ export function autometrics( const autometricsDuration = (performance.now() - autometricsStart) / 1000; counter.add(1, { - function: functionName, - module: moduleName, - result: "ok", - caller_function: callerFunction, - caller_module: callerModule, + [FUNCTION_LABEL]: functionName, + [MODULE_LABEL]: moduleName, + [RESULT_LABEL]: "ok", + [CALLER_FUNCTION_LABEL]: callerFunction, + [CALLER_MODULE_LABEL]: callerModule, ...counterObjectiveAttributes, }); diff --git a/packages/autometrics/tests/buildInfo.test.ts b/packages/autometrics/tests/buildInfo.test.ts index a2ccb1a..6b434e1 100644 --- a/packages/autometrics/tests/buildInfo.test.ts +++ b/packages/autometrics/tests/buildInfo.test.ts @@ -1,6 +1,12 @@ -import { assertMatch } from "$std/assert/mod.ts"; +import { assertStringIncludes } from "$std/assert/mod.ts"; import { recordBuildInfo } from "../mod.ts"; +import { + BRANCH_LABEL, + COMMIT_LABEL, + REPOSITORY_URL_LABEL, + VERSION_LABEL, +} from "../src/constants.ts"; import { collectAndSerialize, stepWithMetricReader } from "./testUtils.ts"; Deno.test("Build info tests", async (t) => { @@ -9,16 +15,18 @@ Deno.test("Build info tests", async (t) => { "build info is recorded", async (metricReader) => { recordBuildInfo({ - version: "1.0.0", - commit: "123456789", - branch: "main", + [VERSION_LABEL]: "1.0.1", + [COMMIT_LABEL]: "123456789", + [BRANCH_LABEL]: "main", + [REPOSITORY_URL_LABEL]: + "https://github.com/autometrics-dev/autometrics-ts.git", }); const serialized = await collectAndSerialize(metricReader); - assertMatch( + assertStringIncludes( serialized, - /build_info{version="1.0.0",commit="123456789",branch="main",clearmode=""}/gm, + 'build_info{autometrics_version="1.0.0",branch="main",commit="123456789",repository_provider="github",repository_url="https://github.com/autometrics-dev/autometrics-ts.git",version="1.0.1",clearmode=""}', ); }, ); diff --git a/packages/autometrics/tests/platformUtils.test.ts b/packages/autometrics/tests/platformUtils.test.ts new file mode 100644 index 0000000..249c1dc --- /dev/null +++ b/packages/autometrics/tests/platformUtils.test.ts @@ -0,0 +1,81 @@ +import { assertEquals } from "$std/assert/mod.ts"; + +import { getGitRepositoryUrl } from "../src/platformUtils.ts"; + +Deno.test("Platform utils tests", async (t) => { + await t.step("extracts Git URL", () => { + assertEquals( + getGitRepositoryUrl( + new TextEncoder().encode( + `[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true +[remote "origin"] + url = git@github.com:autometrics-dev/autometrics-ts.git + fetch = +refs/heads/*:refs/remotes/origin/*`, + ), + ), + "git@github.com:autometrics-dev/autometrics-ts.git", + ); + }); + + await t.step("extracts quoted Git URL", () => { + assertEquals( + getGitRepositoryUrl( + new TextEncoder().encode( + `[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true +[remote "origin"] + url = "git@github.com:autometrics-dev/autometrics-ts.git" + fetch = +refs/heads/*:refs/remotes/origin/*`, + ), + ), + "git@github.com:autometrics-dev/autometrics-ts.git", + ); + }); + + await t.step("doesn't return upstream URL", () => { + assertEquals( + getGitRepositoryUrl( + new TextEncoder().encode( + `[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true +[remote "upstream"] + url = git@github.com:autometrics-dev/autometrics-ts.git + fetch = +refs/heads/*:refs/remotes/origin/* +[remote "origin"] + url = git@github.com:arendjr/autometrics-ts.git + fetch = +refs/heads/*:refs/remotes/origin/*`, + ), + ), + "git@github.com:arendjr/autometrics-ts.git", + ); + }); + + await t.step("doesn't return commented URL", () => { + assertEquals( + getGitRepositoryUrl( + new TextEncoder().encode( + `[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true +[remote "origin"] + #url = git@github.com:autometrics-dev/autometrics-ts.git + url = git@github.com:arendjr/autometrics-ts.git + fetch = +refs/heads/*:refs/remotes/origin/*`, + ), + ), + "git@github.com:arendjr/autometrics-ts.git", + ); + }); +});