diff --git a/src/index.ts b/src/index.ts index 656b2aac..d33ac873 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,8 +9,10 @@ import registerDebugEndpoints from './debug/router' import { buildMetricsMiddleware, setupMetricsServer } from './metrics' import { AdapterRequest, + AdapterResponse, AdapterRouteGeneric, censorLogs, + getVersions, loggingContextMiddleware, makeLogger, } from './util' @@ -193,6 +195,32 @@ export const expose = async ( return api } +/** + * Adds the adapter and framework versions to a response's metadata, right before it is sent back. + * + * This is deliberately done at egress rather than when the response is written to the cache: cache + * entries are shared across instances and outlive deployments, so a version baked into them could + * describe a different instance than the one actually serving the response. + * + * @param response - the response about to be sent back + * @param adapter - the adapter serving the response + * @param req - the incoming request the response is for + * @returns a copy of the response with versions added to its metadata + */ +const withVersions = ( + response: Readonly, + adapter: Adapter, + req: AdapterRequest, +): AdapterResponse => ({ + ...response, + meta: { + ...response.meta, + adapterName: response.meta?.adapterName ?? adapter.name, + transportName: response.meta?.transportName ?? req.requestContext.transportName, + versions: getVersions(), + }, +}) + async function buildRestApi(adapter: Adapter) { const TLSOptions: httpsOptions | Record = getTLSOptions(adapter.config.settings) const app = fastify({ @@ -234,12 +262,15 @@ async function buildRestApi(adapter: Adapter) { url: adapter.config.settings.BASE_URL, method: 'POST', handler: async (req, reply) => { + const adapterRequest = req as AdapterRequest const response = await adapter.handleRequestWithValidation( - req as AdapterRequest, + adapterRequest, reply as unknown as Promise, ) - return reply.code(response.statusCode || 200).send(response) + return reply + .code(response.statusCode || 200) + .send(withVersions(response, adapter, adapterRequest)) }, }) diff --git a/src/metrics/index.ts b/src/metrics/index.ts index ca082f87..738bb88a 100644 --- a/src/metrics/index.ts +++ b/src/metrics/index.ts @@ -3,7 +3,13 @@ import { join } from 'path' import * as client from 'prom-client' import { AdapterSettings } from '../config' import { getTLSOptions, httpsOptions } from '../index' -import { AdapterRequest, censorLogs, makeLogger } from '../util' +import { + AdapterRequest, + censorLogs, + getAdapterVersion, + getFrameworkVersion, + makeLogger, +} from '../util' import { AdapterError } from '../validation/error' import { EmptyInputParameters } from '../validation/input-params' import { HttpRequestType, requestDurationBuckets } from './constants' @@ -42,9 +48,12 @@ export function setupMetricsServer(name: string, adapterSettings: AdapterSetting export const setupMetrics = (name: string): void => { client.collectDefaultMetrics() + // Note the "app_" prefix is meaningful: TestMetrics strips labels with that prefix so that + // assertions don't have to restate the app-level default labels on every metric client.register.setDefaultLabels({ app_name: name || 'N/A', - app_version: process.env['npm_package_version'], + app_version: getAdapterVersion(), + app_framework_version: getFrameworkVersion(), }) } diff --git a/src/util/index.ts b/src/util/index.ts index 2e8a4e29..2caafcfa 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -1,6 +1,7 @@ export * from './logger' export * from './subscription-set/subscription-set' export * from './types' +export * from './version' /** * Sleeps for the provided number of milliseconds diff --git a/src/util/logger.ts b/src/util/logger.ts index 9a49ae2a..f8010c55 100644 --- a/src/util/logger.ts +++ b/src/util/logger.ts @@ -1,12 +1,14 @@ import { randomUUID } from 'crypto' import { FastifyReply, FastifyRequest, HookHandlerDoneFunction } from 'fastify' import { AsyncLocalStorage } from 'node:async_hooks' +import { hostname } from 'os' import pino from 'pino' import pretty from 'pino-pretty' import { BaseSettingsDefinition } from '../config' import { EmptyInputParameters } from '../validation/input-params' import CensorList, { CensorKeyValue } from './censor/censor-list' import { AdapterRequest } from './types' +import { getAdapterVersion, getFrameworkVersion } from './version' export const asyncLocalStorage = new AsyncLocalStorage() @@ -17,7 +19,7 @@ export type Store = { const stream = pretty({ levelFirst: true, levelLabel: 'level', - ignore: 'layer,pid,hostname,correlationId,color', + ignore: 'layer,pid,hostname,correlationId,color,adapterVersion,frameworkVersion', messageFormat: `\x1b[0m[{correlationId}] {color}[{layer}]\x1b[0m {msg}`, translateTime: 'yyyy-mm-dd HH:MM:ss.l', }) @@ -26,6 +28,14 @@ const stream = pretty({ const baseLogger = pino( { level: process.env['LOG_LEVEL']?.toLowerCase() || BaseSettingsDefinition.LOG_LEVEL.default, + // Note pino's default base is { pid, hostname }, so both have to be restated here to be kept. + // Resolved once on logger construction, so this adds no per-log-line cost. + base: { + pid: process.pid, + hostname: hostname(), + adapterVersion: getAdapterVersion(), + frameworkVersion: getFrameworkVersion(), + }, formatters: { level(label) { return { level: label } diff --git a/src/util/types.ts b/src/util/types.ts index 7cedf15c..9dbd329a 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -2,6 +2,7 @@ import { FastifyReply, FastifyRequest, HookHandlerDoneFunction } from 'fastify' import { Adapter } from '../adapter' import { AdapterError } from '../validation/error' import { InputParametersDefinition, TypeFromDefinition } from '../validation/input-params' +import { AdapterVersions } from './version' declare module 'fastify' { export interface FastifyRequest { requestContext: AdapterRequestContext @@ -91,6 +92,8 @@ export interface AdapterResponseMeta extends AdapterRequestMeta { adapterName: string /** Name of the transport */ transportName: string + /** Versions of the adapter and framework that produced this response */ + versions?: AdapterVersions } /** @@ -214,11 +217,18 @@ type ProviderErrorResponse = { /** Error message that will be sent back from the adapter */ errorMessage: string + + /** + * Metadata relevant to this response. + * Not set by transports; the framework fills this in when the response is sent back. + */ + meta?: AdapterResponseMeta } & { - // Ensure the union types below (e.g. [[AdapterResponse]]) are mutually exclusive + // Ensure the union types below (e.g. [[AdapterResponse]]) are mutually exclusive. + // Note "meta" is deliberately not one of these markers: "data" and "result" already make the + // union discriminable, and provider error responses need to be able to carry metadata. data?: never result?: never - meta?: never } /** diff --git a/src/util/version.ts b/src/util/version.ts new file mode 100644 index 00000000..862b8cc3 --- /dev/null +++ b/src/util/version.ts @@ -0,0 +1,100 @@ +import { readFileSync } from 'fs' +import { dirname, join } from 'path' + +// NOTE: this module must not import anything else from this project. +// It's imported by the logger, which in turn is imported almost everywhere, so any +// project-local import here risks introducing a cycle. + +/** Name of this package, used to identify the framework's own package.json */ +const FRAMEWORK_PACKAGE_NAME = '@chainlink/external-adapter-framework' + +/** Value reported when a version could not be determined */ +export const UNKNOWN_VERSION = 'unknown' + +/** + * Versions of the code that produced a response, message or metric. + */ +export interface AdapterVersions { + /** Version of the external adapter, read from its package.json */ + adapter: string + + /** Version of the EA framework the adapter is built against */ + framework: string +} + +/** + * Walks up the directory tree from the provided starting point, looking for a package.json to read + * the version from. Unreadable or malformed manifests are skipped, as are ones that don't match + * `expectedName` when it is provided. + * + * @param startDir - the directory to start searching from + * @param expectedName - if provided, only consider a package.json whose "name" matches this + * @returns the version found, or undefined if the filesystem root is reached without a match + */ +export const resolvePackageVersion = ( + startDir: string, + expectedName?: string, +): string | undefined => { + let dir = startDir + // The dirname of the filesystem root is the root itself, which is how the walk terminates + for (let previous = ''; dir !== previous; dir = dirname(dir)) { + previous = dir + try { + const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8')) + const matchesName = !expectedName || manifest?.['name'] === expectedName + if (matchesName && typeof manifest?.['version'] === 'string') { + return manifest['version'] + } + } catch { + // No readable package.json in this directory, keep walking up + } + } + + return undefined +} + +let frameworkVersion: string | undefined +let adapterVersion: string | undefined + +/** + * Version of the EA framework, read from this package's own package.json. + * The result is memoized after the first call. + * + * @returns the framework version, or "unknown" if it could not be determined + */ +export const getFrameworkVersion = (): string => { + frameworkVersion ??= resolvePackageVersion(__dirname, FRAMEWORK_PACKAGE_NAME) ?? UNKNOWN_VERSION + return frameworkVersion +} + +/** + * Version of the external adapter, read from the nearest package.json at or above the current + * working directory. Falls back to the npm_package_version environment variable, which is only set + * when the process was started through an npm/yarn script. The result is memoized after the first + * call. + * + * @returns the adapter version, or "unknown" if it could not be determined + */ +export const getAdapterVersion = (): string => { + adapterVersion ??= + resolvePackageVersion(process.cwd()) ?? process.env['npm_package_version'] ?? UNKNOWN_VERSION + return adapterVersion +} + +/** + * Both the adapter and framework versions, for inclusion in responses, messages and metrics. + * + * @returns the adapter and framework versions + */ +export const getVersions = (): AdapterVersions => ({ + adapter: getAdapterVersion(), + framework: getFrameworkVersion(), +}) + +/** + * Clears the memoized versions. Only intended for use in tests. + */ +export const resetVersionCache = (): void => { + frameworkVersion = undefined + adapterVersion = undefined +} diff --git a/src/validation/error.ts b/src/validation/error.ts index 8a114c18..8294a23c 100644 --- a/src/validation/error.ts +++ b/src/validation/error.ts @@ -1,5 +1,8 @@ import { HttpRequestType } from '../metrics/constants' import { ResponseTimestamps } from '../util' +// Imported directly rather than through the "util" barrel: there is an existing import cycle +// between util and validation, and version.ts has no project-local imports of its own. +import { AdapterVersions, getVersions } from '../util/version' type ErrorBasic = { name: string @@ -15,6 +18,9 @@ export type AdapterErrorResponse = { statusCode: number providerStatusCode?: number error: ErrorBasic | ErrorFull + + /** Versions of the adapter and framework that produced this error */ + versions: AdapterVersions } export class AdapterError extends Error { @@ -75,6 +81,7 @@ export class AdapterError extends Error { statusCode: this.statusCode, providerStatusCode: this.providerStatusCode, error: showDebugInfo ? errorFull : errorBasic, + versions: getVersions(), } } } diff --git a/test/error.test.ts b/test/error.test.ts index 20d59487..34fc8b06 100644 --- a/test/error.test.ts +++ b/test/error.test.ts @@ -1,7 +1,7 @@ import untypedTest, { ExecutionContext, TestFn } from 'ava' import { ReplyError as RedisError } from 'ioredis' import { Adapter, AdapterEndpoint } from '../src/adapter' -import { AdapterResponse, ResponseTimestamps } from '../src/util' +import { AdapterResponse, ResponseTimestamps, getVersions } from '../src/util' import { AdapterConnectionError, AdapterCustomError, @@ -90,6 +90,7 @@ test('Adapter error returns default status of 500', async (t) => { }, status: 'errored', statusCode: 500, + versions: getVersions(), }) }) @@ -122,6 +123,7 @@ test('Adapter error returns specified 200, with accompanying provider status cod status: 'errored', statusCode: 200, providerStatusCode: 504, + versions: getVersions(), }) }) diff --git a/test/lwba.test.ts b/test/lwba.test.ts index 7a76f517..aa15a20c 100644 --- a/test/lwba.test.ts +++ b/test/lwba.test.ts @@ -14,7 +14,7 @@ import { lwbaEndpointInputParametersDefinition, priceEndpointInputParametersDefinition, } from '../src/adapter' -import { AdapterRequest, AdapterResponse } from '../src/util' +import { AdapterRequest, AdapterResponse, getVersions } from '../src/util' import { EmptyCustomSettings } from '../src/config' import { TypeFromDefinition } from '../src/validation/input-params' import { Transport } from '../src/transports' @@ -177,6 +177,7 @@ test('Invariant violation fails LWBA validation (bid <= mid <= ask)', async (t) message: 'Invariant violation. Mid price must be between bid and ask prices. Got: (bid: 123.1, mid: 123.4, ask: 123.3)', }, + versions: getVersions(), }) const response = await testAdapter.request({ @@ -222,6 +223,7 @@ test('Invariant violation fails LWBA validation (bid, mid or ask not found)', as message: 'Invariant violation. LWBA response must contain mid, bid and ask prices. Got: (bid: null, mid: 123.4, ask: 123.3)', }, + versions: getVersions(), }) const response = await testAdapter.request({ diff --git a/test/metrics/metrics.test.ts b/test/metrics/metrics.test.ts index 6b9730ca..9fc1e28b 100644 --- a/test/metrics/metrics.test.ts +++ b/test/metrics/metrics.test.ts @@ -9,6 +9,7 @@ import { Metrics, retrieveCost } from '../../src/metrics' import { HttpTransport } from '../../src/transports' import { InputParameters } from '../../src/validation' import { TestAdapter } from '../../src/util/testing-utils' +import { getVersions } from '../../src/util' const test = untypedTest as TestFn<{ testAdapter: TestAdapter @@ -333,6 +334,7 @@ test.serial('validate response.meta has the correct properties', async (t) => { adapterName: 'TEST', metrics: { feedId: '{"from":"eth","to":"usd"}' }, transportName: 'default_single_transport', + versions: getVersions(), }) }) diff --git a/test/util/version.test.ts b/test/util/version.test.ts new file mode 100644 index 00000000..ba0c2ab8 --- /dev/null +++ b/test/util/version.test.ts @@ -0,0 +1,149 @@ +import test from 'ava' +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { + UNKNOWN_VERSION, + getAdapterVersion, + getFrameworkVersion, + getVersions, + resetVersionCache, + resolvePackageVersion, +} from '../../src/util/version' + +const FRAMEWORK_PACKAGE_NAME = '@chainlink/external-adapter-framework' + +// Read this repo's own manifest directly, so the tests assert against the real version rather than +// a literal that would need updating on every release. Ava runs from the repo root. +const repoManifest = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8')) + +/** + * Builds a nested directory tree under a fresh temp dir, writing the provided package.json contents + * at each level (index 0 being the outermost). Returns the deepest directory, to be used as the + * starting point for a walk. + */ +const makeTree = (levels: (string | undefined)[]) => { + let dir = mkdtempSync(join(tmpdir(), 'ea-version-test-')) + for (const [index, contents] of levels.entries()) { + dir = join(dir, `level-${index}`) + mkdirSync(dir) + if (contents !== undefined) { + writeFileSync(join(dir, 'package.json'), contents) + } + } + return dir +} + +test.beforeEach(() => { + resetVersionCache() +}) + +test.serial('Sanity check: tests run from the framework repo root', (t) => { + t.is(repoManifest.name, FRAMEWORK_PACKAGE_NAME) + t.is(typeof repoManifest.version, 'string') +}) + +test.serial('resolvePackageVersion finds the version in the starting directory', (t) => { + const dir = makeTree([JSON.stringify({ name: 'some-adapter', version: '1.2.3' })]) + t.is(resolvePackageVersion(dir), '1.2.3') +}) + +test.serial('resolvePackageVersion walks up the tree to find a package.json', (t) => { + const dir = makeTree([ + JSON.stringify({ name: 'some-adapter', version: '4.5.6' }), + undefined, + undefined, + ]) + t.is(resolvePackageVersion(dir), '4.5.6') +}) + +test.serial('resolvePackageVersion skips manifests that do not match the expected name', (t) => { + const dir = makeTree([ + JSON.stringify({ name: 'the-framework', version: '9.9.9' }), + JSON.stringify({ name: 'some-adapter', version: '1.0.0' }), + ]) + t.is(resolvePackageVersion(dir, 'the-framework'), '9.9.9') +}) + +test.serial('resolvePackageVersion skips malformed manifests and keeps walking', (t) => { + const dir = makeTree([ + JSON.stringify({ name: 'some-adapter', version: '7.8.9' }), + 'not json at all', + ]) + t.is(resolvePackageVersion(dir), '7.8.9') +}) + +test.serial('resolvePackageVersion skips manifests with no version and keeps walking', (t) => { + const dir = makeTree([ + JSON.stringify({ name: 'some-adapter', version: '2.0.0' }), + JSON.stringify({ name: 'no-version-here' }), + ]) + t.is(resolvePackageVersion(dir), '2.0.0') +}) + +test.serial('resolvePackageVersion returns undefined when it reaches the filesystem root', (t) => { + const dir = makeTree([JSON.stringify({ name: 'some-adapter', version: '1.0.0' })]) + // No manifest anywhere up the tree carries this name, so the walk runs out of parents + t.is(resolvePackageVersion(dir, 'a-name-that-is-not-in-any-manifest'), undefined) +}) + +test.serial("getFrameworkVersion resolves this package's own version", (t) => { + t.is(getFrameworkVersion(), repoManifest.version) + // Confirm it is really this package that gets matched, rather than an unrelated ancestor manifest + t.is(resolvePackageVersion(__dirname, FRAMEWORK_PACKAGE_NAME), repoManifest.version) +}) + +test.serial('getFrameworkVersion returns the memoized value on subsequent calls', (t) => { + const first = getFrameworkVersion() + t.is(getFrameworkVersion(), first) +}) + +test.serial('getAdapterVersion resolves from the working directory', (t) => { + // Ava runs from the repo root, so the nearest manifest is this repo's + t.is(getAdapterVersion(), repoManifest.version) + t.is(getAdapterVersion(), repoManifest.version) +}) + +test.serial('getAdapterVersion falls back to npm_package_version', (t) => { + const originalCwd = process.cwd + const originalEnv = process.env['npm_package_version'] + + try { + // The filesystem root has no manifest, so the walk finds nothing and terminates immediately + process.cwd = () => '/' + process.env['npm_package_version'] = '3.2.1' + resetVersionCache() + t.is(getAdapterVersion(), '3.2.1') + } finally { + process.cwd = originalCwd + if (originalEnv === undefined) { + delete process.env['npm_package_version'] + } else { + process.env['npm_package_version'] = originalEnv + } + } +}) + +test.serial('getAdapterVersion falls back to "unknown"', (t) => { + const originalCwd = process.cwd + const originalEnv = process.env['npm_package_version'] + + try { + process.cwd = () => '/' + delete process.env['npm_package_version'] + resetVersionCache() + t.is(getAdapterVersion(), UNKNOWN_VERSION) + } finally { + process.cwd = originalCwd + if (originalEnv !== undefined) { + process.env['npm_package_version'] = originalEnv + } + } +}) + +test.serial('getVersions returns both the adapter and framework versions', (t) => { + t.deepEqual(getVersions(), { + adapter: repoManifest.version, + framework: repoManifest.version, + }) +}) diff --git a/test/versions.test.ts b/test/versions.test.ts new file mode 100644 index 00000000..58c73ded --- /dev/null +++ b/test/versions.test.ts @@ -0,0 +1,112 @@ +import untypedTest, { TestFn } from 'ava' +import { Adapter, AdapterEndpoint } from '../src/adapter' +import { EmptyCustomSettings } from '../src/config' +import { AdapterRequest, AdapterResponse, getVersions } from '../src/util' +import { NopTransport, TestAdapter } from '../src/util/testing-utils' +import { EmptyInputParameters, TypeFromDefinition } from '../src/validation/input-params' + +const test = untypedTest as TestFn<{ + testAdapter: TestAdapter +}> + +const price = 1234 + +type VersionTestTransportTypes = { + Parameters: EmptyInputParameters + Response: { + Data: { result: number } + Result: number + } + Settings: EmptyCustomSettings +} + +/** + * Writes the response to the cache and also returns it directly, so the first request is served by + * the transport's immediate response and any subsequent one is served from the cache. Both paths go + * through the same egress point, which is what these tests are about. + */ +class VersionTestTransport extends NopTransport { + override async foregroundExecute( + req: AdapterRequest>, + ): Promise> { + const response = { + data: { result: price }, + result: price, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + } + + await this.responseCache.write(this.name, [{ params: req.requestContext.data, response }]) + + return { ...response, statusCode: 200 } + } +} + +test.beforeEach(async (t) => { + const adapter = new Adapter({ + name: 'TEST', + defaultEndpoint: 'test', + endpoints: [ + new AdapterEndpoint({ + name: 'test', + transport: new VersionTestTransport(), + }), + ], + }) + + t.context.testAdapter = await TestAdapter.startWithMockedCache(adapter, t.context) +}) + +test.afterEach(async (t) => { + await t.context.testAdapter?.api.close() +}) + +test.serial('successful response includes the adapter and framework versions', async (t) => { + const response = await t.context.testAdapter.request({}) + + t.is(response.statusCode, 200) + t.deepEqual(response.json().meta.versions, getVersions()) +}) + +test.serial('response metadata is populated even when metrics are disabled', async (t) => { + // METRICS_ENABLED is false for this test suite, so the response cache leaves "meta" unset and the + // egress helper is what fills all of this in + const response = await t.context.testAdapter.request({}) + + t.deepEqual(response.json().meta, { + adapterName: 'TEST', + transportName: 'default_single_transport', + versions: getVersions(), + }) +}) + +test.serial('cached response includes the versions of the instance serving it', async (t) => { + // First request populates the cache and is served by the transport's immediate response + await t.context.testAdapter.request({}) + // Second request is served from the cache + const cached = await t.context.testAdapter.request({}) + + t.is(cached.statusCode, 200) + t.deepEqual(cached.json().meta.versions, getVersions()) +}) + +test.serial('serving a response does not write versions back into the cache', async (t) => { + // The second request is served straight from the cache, which is where a mutating egress helper + // would leak metadata into the cached object + await t.context.testAdapter.request({}) + await t.context.testAdapter.request({}) + + const cachedEntries = [...t.context.testAdapter.mockCache!.cache.values()].map( + (node) => node.data.value as AdapterResponse, + ) + + t.true(cachedEntries.length > 0) + for (const entry of cachedEntries) { + // Cache entries are shared across instances and outlive deployments, so they must not carry the + // version of whichever instance happened to serve a request from them + t.is(entry.meta, undefined) + } +})