Skip to content
Open
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
35 changes: 33 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import registerDebugEndpoints from './debug/router'
import { buildMetricsMiddleware, setupMetricsServer } from './metrics'
import {
AdapterRequest,
AdapterResponse,
AdapterRouteGeneric,
censorLogs,
getVersions,
loggingContextMiddleware,
makeLogger,
} from './util'
Expand Down Expand Up @@ -193,6 +195,32 @@ export const expose = async <T extends SettingsDefinitionMap>(
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<AdapterResponse>,
adapter: Adapter,
req: AdapterRequest<EmptyInputParameters>,
): 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<string, unknown> = getTLSOptions(adapter.config.settings)
const app = fastify({
Expand Down Expand Up @@ -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<EmptyInputParameters>
const response = await adapter.handleRequestWithValidation(
req as AdapterRequest<EmptyInputParameters>,
adapterRequest,
reply as unknown as Promise<unknown>,
)

return reply.code(response.statusCode || 200).send(response)
return reply
.code(response.statusCode || 200)
.send(withVersions(response, adapter, adapterRequest))
},
})

Expand Down
13 changes: 11 additions & 2 deletions src/metrics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(),
})
}

Expand Down
1 change: 1 addition & 0 deletions src/util/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/util/logger.ts
Original file line number Diff line number Diff line change
@@ -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()

Expand All @@ -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',
})
Expand All @@ -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 }
Expand Down
14 changes: 12 additions & 2 deletions src/util/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>
Expand Down Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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
}

/**
Expand Down
100 changes: 100 additions & 0 deletions src/util/version.ts
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 7 additions & 0 deletions src/validation/error.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -75,6 +81,7 @@ export class AdapterError extends Error {
statusCode: this.statusCode,
providerStatusCode: this.providerStatusCode,
error: showDebugInfo ? errorFull : errorBasic,
versions: getVersions(),
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion test/error.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -90,6 +90,7 @@ test('Adapter error returns default status of 500', async (t) => {
},
status: 'errored',
statusCode: 500,
versions: getVersions(),
})
})

Expand Down Expand Up @@ -122,6 +123,7 @@ test('Adapter error returns specified 200, with accompanying provider status cod
status: 'errored',
statusCode: 200,
providerStatusCode: 504,
versions: getVersions(),
})
})

Expand Down
4 changes: 3 additions & 1 deletion test/lwba.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions test/metrics/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
})
})

Expand Down
Loading
Loading