feature: add telemetry to the HTTP layer (#9116)

---------

Co-authored-by: adrien2p <adrien.deperetti@gmail.com>
This commit is contained in:
Harminder Virk
2024-09-13 12:36:54 +05:30
committed by GitHub
co-authored by adrien2p
parent 8c2a5fbcf2
commit 9cf0df53b5
12 changed files with 951 additions and 44 deletions
@@ -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
+58 -6
View File
@@ -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
+1
View File
@@ -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
}