feature: add telemetry to the HTTP layer (#9116)
--------- Co-authored-by: adrien2p <adrien.deperetti@gmail.com>
This commit is contained in:
co-authored by
adrien2p
parent
8c2a5fbcf2
commit
9cf0df53b5
@@ -7,7 +7,11 @@ import { NextFunction } from "express"
|
||||
import { MedusaRequest } from "../../../../types/routing"
|
||||
|
||||
export function maybeApplyPriceListsFilter() {
|
||||
return async (req: MedusaRequest, _, next: NextFunction) => {
|
||||
return async function applyPriceListsFilter(
|
||||
req: MedusaRequest,
|
||||
_,
|
||||
next: NextFunction
|
||||
) {
|
||||
const filterableFields: HttpTypes.AdminProductListParams =
|
||||
req.filterableFields
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export function maybeApplyLinkFilter({
|
||||
filterableField,
|
||||
filterByField = "id",
|
||||
}) {
|
||||
return async (req: MedusaRequest, _, next: NextFunction) => {
|
||||
return async function linkFilter(req: MedusaRequest, _, next: NextFunction) {
|
||||
const filterableFields = req.filterableFields
|
||||
|
||||
if (!filterableFields?.[filterableField]) {
|
||||
@@ -76,27 +76,26 @@ export function maybeApplyLinkFilter({
|
||||
}
|
||||
*/
|
||||
function transformFilterableFields(filterableFields: Record<string, unknown>) {
|
||||
const result = {};
|
||||
const result = {}
|
||||
for (const key of Object.keys(filterableFields)) {
|
||||
const value = filterableFields[key];
|
||||
const keys = key.split(".");
|
||||
let current = result;
|
||||
const value = filterableFields[key]
|
||||
const keys = key.split(".")
|
||||
let current = result
|
||||
|
||||
// Iterate over the keys, creating nested objects as needed
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const part = keys[i];
|
||||
current[part] ??= {};
|
||||
const part = keys[i]
|
||||
current[part] ??= {}
|
||||
|
||||
if (i === keys.length - 1) {
|
||||
// If its the last key, assign the value
|
||||
current[part] = value;
|
||||
break;
|
||||
current[part] = value
|
||||
break
|
||||
}
|
||||
|
||||
current = current[part];
|
||||
current = current[part]
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@ export function validateAndTransformBody(
|
||||
res: MedusaResponse,
|
||||
next: NextFunction
|
||||
) => Promise<void> {
|
||||
return async (req: MedusaRequest, _: MedusaResponse, next: NextFunction) => {
|
||||
return async function validateBody(
|
||||
req: MedusaRequest,
|
||||
_: MedusaResponse,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
let schema: z.ZodObject<any, any> | z.ZodEffects<any, any>
|
||||
if (typeof zodSchema === "function") {
|
||||
|
||||
@@ -65,7 +65,11 @@ export function validateAndTransformQuery<TEntity extends BaseEntity>(
|
||||
res: MedusaResponse,
|
||||
next: NextFunction
|
||||
) => Promise<void> {
|
||||
return async (req: MedusaRequest, _: MedusaResponse, next: NextFunction) => {
|
||||
return async function validateQuery(
|
||||
req: MedusaRequest,
|
||||
_: MedusaResponse,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const allowed = (req.allowed ?? queryConfig.allowed ?? []) as string[]
|
||||
delete req.allowed
|
||||
|
||||
@@ -1,21 +1,59 @@
|
||||
import path from "path"
|
||||
import express from "express"
|
||||
import { track } from "medusa-telemetry"
|
||||
import { scheduleJob } from "node-schedule"
|
||||
|
||||
import { gqlSchemaToTypes, logger } from "@medusajs/framework"
|
||||
import { GracefulShutdownServer } from "@medusajs/utils"
|
||||
import http, { IncomingMessage, ServerResponse } from "http"
|
||||
import { gqlSchemaToTypes, logger } from "@medusajs/framework"
|
||||
|
||||
import loaders from "../loaders"
|
||||
import path from "path"
|
||||
|
||||
const EVERY_SIXTH_HOUR = "0 */6 * * *"
|
||||
const CRON_SCHEDULE = EVERY_SIXTH_HOUR
|
||||
|
||||
export default async function ({ port, directory, types }) {
|
||||
async function start() {
|
||||
/**
|
||||
* Imports the "instrumentation.js" file from the root of the
|
||||
* directory and invokes the register function. The existence
|
||||
* of this file is optional, hence we ignore "ENOENT"
|
||||
* errors.
|
||||
*/
|
||||
async function registerInstrumentation(directory: string) {
|
||||
try {
|
||||
const instrumentation = await import(
|
||||
path.join(directory, "instrumentation.js")
|
||||
)
|
||||
if (typeof instrumentation.register === "function") {
|
||||
logger.info("OTEL registered")
|
||||
instrumentation.register()
|
||||
}
|
||||
} catch (error) {
|
||||
if (!["ENOENT", "MODULE_NOT_FOUND"].includes(error.code)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function start({ port, directory, types }) {
|
||||
async function internalStart() {
|
||||
track("CLI_START")
|
||||
await registerInstrumentation(directory)
|
||||
|
||||
const app = express()
|
||||
|
||||
const http_ = http.createServer(async (req, res) => {
|
||||
await start.traceRequestHandler(
|
||||
async () => {
|
||||
return new Promise((resolve) => {
|
||||
res.on("finish", resolve)
|
||||
app(req, res)
|
||||
})
|
||||
},
|
||||
req,
|
||||
res
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
const { shutdown, gqlSchema } = await loaders({
|
||||
directory,
|
||||
@@ -33,7 +71,7 @@ export default async function ({ port, directory, types }) {
|
||||
|
||||
const serverActivity = logger.activity(`Creating server`)
|
||||
const server = GracefulShutdownServer.create(
|
||||
app.listen(port).on("listening", () => {
|
||||
http_.listen(port).on("listening", () => {
|
||||
logger.success(serverActivity, `Server is ready on port: ${port}`)
|
||||
track("CLI_START_COMPLETED")
|
||||
})
|
||||
@@ -68,5 +106,19 @@ export default async function ({ port, directory, types }) {
|
||||
}
|
||||
}
|
||||
|
||||
await start()
|
||||
await internalStart()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap request handler inside custom implementation to enabled
|
||||
* instrumentation.
|
||||
*/
|
||||
start.traceRequestHandler = async (
|
||||
requestHandler: () => Promise<void>,
|
||||
_: IncomingMessage,
|
||||
__: ServerResponse
|
||||
) => {
|
||||
return await requestHandler()
|
||||
}
|
||||
|
||||
export default start
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from "./types/middlewares"
|
||||
export * from "./types/routing"
|
||||
export * from "./types/subscribers"
|
||||
export * from "./utils"
|
||||
export * from "./instrumentation"
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { snakeCase } from "lodash"
|
||||
import { NodeSDK } from "@opentelemetry/sdk-node"
|
||||
import { Resource } from "@opentelemetry/resources"
|
||||
import { SpanStatusCode } from "@opentelemetry/api"
|
||||
import { RoutesLoader, Tracer } from "@medusajs/framework"
|
||||
import {
|
||||
type SpanExporter,
|
||||
SimpleSpanProcessor,
|
||||
} from "@opentelemetry/sdk-trace-node"
|
||||
import { PgInstrumentation } from "@opentelemetry/instrumentation-pg"
|
||||
import type { Instrumentation } from "@opentelemetry/instrumentation"
|
||||
|
||||
import start from "../commands/start"
|
||||
|
||||
const EXCLUDED_RESOURCES = [".vite", "virtual:"]
|
||||
|
||||
function shouldExcludeResource(resource: string) {
|
||||
return EXCLUDED_RESOURCES.some((excludedResource) =>
|
||||
resource.includes(excludedResource)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Instrumenting the first touch point of the Http layer to report traces to
|
||||
* OpenTelemetry
|
||||
*/
|
||||
export function instrumentHttpLayer() {
|
||||
const HTTPTracer = new Tracer("@medusajs/http", "2.0.0")
|
||||
|
||||
start.traceRequestHandler = async (requestHandler, req, res) => {
|
||||
if (shouldExcludeResource(req.url!)) {
|
||||
return await requestHandler()
|
||||
}
|
||||
|
||||
const traceName = `${req.method} ${req.url}`
|
||||
await HTTPTracer.trace(traceName, async (span) => {
|
||||
span.setAttributes({
|
||||
"http.url": req.url,
|
||||
"http.method": req.method,
|
||||
...req.headers,
|
||||
})
|
||||
|
||||
try {
|
||||
await requestHandler()
|
||||
} finally {
|
||||
span.setAttributes({ "http.statusCode": res.statusCode })
|
||||
span.end()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Instrumenting the route handler to report traces to
|
||||
* OpenTelemetry
|
||||
*/
|
||||
RoutesLoader.instrument.route((handler) => {
|
||||
const traceName = `route: ${
|
||||
handler.name ? snakeCase(handler.name) : `anonymous`
|
||||
}`
|
||||
|
||||
return async (req, res) => {
|
||||
if (shouldExcludeResource(req.originalUrl)) {
|
||||
return await handler(req, res)
|
||||
}
|
||||
|
||||
await HTTPTracer.trace(traceName, async (span) => {
|
||||
try {
|
||||
await handler(req, res)
|
||||
} catch (error) {
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: error.message || "Failed",
|
||||
})
|
||||
} finally {
|
||||
span.end()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Instrumenting the middleware handler to report traces to
|
||||
* OpenTelemetry
|
||||
*/
|
||||
RoutesLoader.instrument.middleware((handler) => {
|
||||
const traceName = `middleware: ${
|
||||
handler.name ? snakeCase(handler.name) : `anonymous`
|
||||
}`
|
||||
|
||||
return async (req, res, next) => {
|
||||
if (shouldExcludeResource(req.originalUrl)) {
|
||||
return handler(req, res, next)
|
||||
}
|
||||
|
||||
await HTTPTracer.trace(traceName, async (span) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const _next = (error?: any) => {
|
||||
if (error) {
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: error.message || "Failed",
|
||||
})
|
||||
span.end()
|
||||
reject(error)
|
||||
} else {
|
||||
span.end()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
handler(req, res, _next)
|
||||
})
|
||||
})
|
||||
.catch(next)
|
||||
.then(next)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function to configure the OpenTelemetry SDK with some defaults.
|
||||
* For better/more control, please configure the SDK manually.
|
||||
*
|
||||
* You will have to install the following packages within your app for
|
||||
* telemetry to work
|
||||
*
|
||||
* - @opentelemetry/sdk-node
|
||||
* - @opentelemetry/resources
|
||||
* - @opentelemetry/sdk-trace-node
|
||||
* - @opentelemetry/instrumentation-pg
|
||||
* - @opentelemetry/instrumentation
|
||||
*/
|
||||
export function registerOtel(options: {
|
||||
serviceName: string
|
||||
exporter: SpanExporter
|
||||
instrumentations?: Instrumentation[]
|
||||
}) {
|
||||
const sdk = new NodeSDK({
|
||||
serviceName: options.serviceName,
|
||||
resource: new Resource({
|
||||
"service.name": options.serviceName,
|
||||
}),
|
||||
spanProcessor: new SimpleSpanProcessor(options.exporter),
|
||||
instrumentations: [
|
||||
new PgInstrumentation(),
|
||||
...(options.instrumentations || []),
|
||||
],
|
||||
})
|
||||
sdk.start()
|
||||
return sdk
|
||||
}
|
||||
Reference in New Issue
Block a user