Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add UI #20

Closed
wants to merge 6 commits into from
Closed
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
Binary file modified bun.lockb
Binary file not shown.
4 changes: 2 additions & 2 deletions bunfig.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[test]

# always enable coverage
coverage = true
coverage = true

15 changes: 14 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,21 @@
"typescript": "^5.0.0"
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.47.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.52.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.52.0",
"@opentelemetry/instrumentation-express": "^0.40.1",
"@opentelemetry/instrumentation-http": "^0.52.0",
"@opentelemetry/instrumentation-winston": "^0.38.0",
"@opentelemetry/sdk-metrics": "^1.25.0",
"@opentelemetry/sdk-node": "^0.52.0",
"@opentelemetry/sdk-trace-node": "^1.25.0",
"@opentelemetry/winston-transport": "^0.4.0",
"env-var": "^7.5.0",
"express": "^4.19.2"
"express": "^4.19.2",
"pug": "^3.0.3",
"winston": "^3.13.0"
},
"scripts": {
"prepare": "if test \"$NODE_ENV\" != \"production\" ; then husky ; fi"
Expand Down
38 changes: 29 additions & 9 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,39 @@
import express from "express";
import express, { Router } from "express";
import { config } from "./init/config";
import { initOtel } from "./init/otel";
import { getLogger } from "./init/logger";
import { getOTELMiddleware, getRequestErrorHandler } from "./middleware";
import { getReqMetrics } from "./init/metrics";

export const app = express();
app.set("view engine", "pug");
app.set("views", "src/views");

app.get("/", (req, res) => {
res.send("Hello World!");
});
export function start() {
initOtel();
const logger = getLogger();
const reqMetrics = getReqMetrics();

app.get("/metadata", (req, res) => {
res.json({
gitSHA: config.gitSHA,
const router = Router();
router.use(getOTELMiddleware(logger, reqMetrics));
router.get("/", (req, res) => {
res.render("index", { title: "Hey", message: "Hello there!" });
});
});

export function start() {
router.get("/metadata/:id", (req, res) => {
res.status(200).json({
gitSHA: config.gitSHA,
});
});

router.get("/error", (req, res, next) => {
next(new Error("This is a test error"));
});

router.use(getRequestErrorHandler(logger));

app.use("/", router);

app.listen(config.port, () => {
console.log(`Server is running on port ${config.port}`);
});
Expand Down
8 changes: 8 additions & 0 deletions src/init/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createLogger } from "winston";
import { OpenTelemetryTransportV3 } from "@opentelemetry/winston-transport";

export function getLogger() {
return createLogger({
transports: [new OpenTelemetryTransportV3()],
});
}
16 changes: 16 additions & 0 deletions src/init/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { metrics, type Counter, type UpDownCounter } from "@opentelemetry/api";

export interface RequestMetrics {
totalRequestsCounter: Counter;
totalErrorsCounter: Counter;
inProgressRequestsGauge: UpDownCounter;
}

export function getReqMetrics(): RequestMetrics {
const meter = metrics.getMeter("request");
return {
totalRequestsCounter: meter.createCounter("requests.total"),
totalErrorsCounter: meter.createCounter("requests.errors"),
inProgressRequestsGauge: meter.createUpDownCounter("requests.in_progress"),
};
}
47 changes: 47 additions & 0 deletions src/init/otel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Resource } from "@opentelemetry/resources";
import os from "os";
import {
SEMRESATTRS_SERVICE_NAME,
SEMRESATTRS_DEPLOYMENT_ENVIRONMENT,
SEMRESATTRS_HOST_NAME,
SEMRESATTRS_HOST_ARCH,
SEMRESATTRS_SERVICE_VERSION,
SEMRESATTRS_PROCESS_PID,
SEMRESATTRS_PROCESS_EXECUTABLE_NAME,
SEMRESATTRS_PROCESS_EXECUTABLE_PATH,
SEMRESATTRS_PROCESS_COMMAND_ARGS,
SEMRESATTRS_PROCESS_RUNTIME_VERSION,
SEMRESATTRS_PROCESS_RUNTIME_NAME,
SEMRESATTRS_PROCESS_RUNTIME_DESCRIPTION,
SEMRESATTRS_PROCESS_COMMAND,
SEMRESATTRS_PROCESS_OWNER,
} from "@opentelemetry/semantic-conventions";
import { initLogging } from "./logs";
import { initMetrics } from "./metrics";
import { initTracing } from "./traces";

const resource = Resource.default().merge(
new Resource({
[SEMRESATTRS_SERVICE_NAME]: process.env.SERVICE_NAME,
[SEMRESATTRS_DEPLOYMENT_ENVIRONMENT]: process.env.DEPLOYMENT_ENVIRONMENT,
[SEMRESATTRS_HOST_NAME]: os.hostname(),
[SEMRESATTRS_HOST_ARCH]: os.arch(),
[SEMRESATTRS_SERVICE_VERSION]: process.env.GIT_SHA,
[SEMRESATTRS_PROCESS_PID]: process.pid,
[SEMRESATTRS_PROCESS_EXECUTABLE_NAME]: process.title,
[SEMRESATTRS_PROCESS_EXECUTABLE_PATH]: process.argv[0],
[SEMRESATTRS_PROCESS_COMMAND_ARGS]: process.argv.slice(1),
[SEMRESATTRS_PROCESS_RUNTIME_VERSION]: process.version,
[SEMRESATTRS_PROCESS_RUNTIME_NAME]: "bun",
[SEMRESATTRS_PROCESS_RUNTIME_DESCRIPTION]:
"Node.js compatible runtime for bun.",
[SEMRESATTRS_PROCESS_COMMAND]: process.argv.join(" "),
[SEMRESATTRS_PROCESS_OWNER]: os.userInfo().username,
}),
);

export function initOtel() {
initLogging(resource);
initMetrics(resource);
initTracing(resource);
}
24 changes: 24 additions & 0 deletions src/init/otel/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as logsAPI from "@opentelemetry/api-logs";
import { Resource } from "@opentelemetry/resources";
import {
LoggerProvider,
SimpleLogRecordProcessor,
BatchLogRecordProcessor,
ConsoleLogRecordExporter,
} from "@opentelemetry/sdk-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";

export function initLogging(resource: Resource) {
const isProduction = process.env.NODE_ENV === "production";
const loggerProvider = new LoggerProvider({ resource });
const exporter = isProduction
? new OTLPLogExporter()
: new ConsoleLogRecordExporter();

const processor = isProduction
? new BatchLogRecordProcessor(exporter)
: new SimpleLogRecordProcessor(exporter);

loggerProvider.addLogRecordProcessor(processor);
logsAPI.logs.setGlobalLoggerProvider(loggerProvider);
}
26 changes: 26 additions & 0 deletions src/init/otel/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import opentelemetry from "@opentelemetry/api";
import {
ConsoleMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { Resource } from "@opentelemetry/resources";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";

export function initMetrics(resource: Resource) {
const isProduction = process.env.NODE_ENV === "production";
const exporter = isProduction
? new OTLPMetricExporter()
: new ConsoleMetricExporter();

const metricReader = new PeriodicExportingMetricReader({
exporter,
});

const meterProvider = new MeterProvider({
resource: resource,
readers: [metricReader],
});

opentelemetry.metrics.setGlobalMeterProvider(meterProvider);
}
23 changes: 23 additions & 0 deletions src/init/otel/traces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Resource } from "@opentelemetry/resources";
import {
SimpleSpanProcessor,
ConsoleSpanExporter,
BatchSpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";

export function initTracing(resource: Resource) {
const traceProvider = new NodeTracerProvider({ resource });
const isProduction = process.env.NODE_ENV === "production";
const exporter = isProduction
? new OTLPTraceExporter()
: new ConsoleSpanExporter();

const spanProcessor = isProduction
? new BatchSpanProcessor(exporter)
: new SimpleSpanProcessor(exporter);

traceProvider.addSpanProcessor(spanProcessor);
traceProvider.register();
}
15 changes: 15 additions & 0 deletions src/middleware/errorLogger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { ErrorRequestHandler } from "express";
import type { Logger } from "winston";

export function getRequestErrorHandler(logger: Logger): ErrorRequestHandler {
return function (err: Error, req, res, next) {
logger.error("request error", {
method: req.method,
path: req.route?.path,
code: 500,
error: err.message,
stackTrace: err.stack,
});
res.status(500).send("Something broke!");
};
}
2 changes: 2 additions & 0 deletions src/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { getRequestErrorHandler } from "./errorLogger";
export { getOTELMiddleware } from "./otel";
16 changes: 16 additions & 0 deletions src/middleware/otel/getReqAttributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Request } from "express";
import type { Attributes } from "@opentelemetry/api";

export function getReqAttributes(req: Request): Attributes {
return {
"http.method": req.method,
"http.scheme": req.protocol,
"http.host": req.get("host"),
"http.target": req.originalUrl,
"http.user_agent": req.get("user-agent"),
"http.flavor": req.httpVersion,
"http.status_code": req.statusCode,
"http.status_text": req.statusMessage,
"express.matched_route": `${req.method} ${req.route?.path}`,
};
}
69 changes: 69 additions & 0 deletions src/middleware/otel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { Handler } from "express";
import type { Span } from "@opentelemetry/api";
import opentelemetry, { SpanStatusCode } from "@opentelemetry/api";
import { Logger } from "winston";
import { getReqAttributes } from "./getReqAttributes";
import type { RequestMetrics } from "../../init/metrics";

export const getOTELMiddleware = (
logger: Logger,
reqMetrics: RequestMetrics,
): Handler => {
return function (req, res, next) {
const start = process.hrtime();
const {
totalRequestsCounter,
totalErrorsCounter,
inProgressRequestsGauge,
} = reqMetrics;
const tracer = opentelemetry.trace.getTracer("http-request");
inProgressRequestsGauge.add(1);

/**
* start an active span with an empty name. this is because the matched route path is not available on the
* the request object (req.route.path) until the request is fully executed by the route handler.
* we update the span name in the res.on("finish") event listener below.
*/
tracer.startActiveSpan("", (span: Span) => {
/**
* TODO: check if bun runtime's issue with triggering req.on("end") has been resolved.
* this should ideally be req.on("end"). However, bun runtime has issues with triggering the end event.
* the res.on("finish") is being used as a workaround.
*/
res.on("finish", () => {
const [_, timeInNanoSeconds] = process.hrtime(start);

const method = req.method;
const path = req.route?.path;
const attributes = getReqAttributes(req);
const reqPath = `${method} ${path}`;
span.updateName(reqPath);
totalRequestsCounter.add(1, attributes);
const code = res.statusCode;
if (code >= 500) {
span.setStatus({ code: SpanStatusCode.ERROR });
totalErrorsCounter.add(1, attributes);
logger.error("request", {
method,
path: req.path,
code,
});
} else {
span.setStatus({ code: SpanStatusCode.OK });
logger.info("request", {
method,
path: req.path,
code,
});
}

span.setAttributes(attributes);
inProgressRequestsGauge.add(-1);
span.end();
});

// call the next middleware
next();
});
};
};
5 changes: 5 additions & 0 deletions src/views/index.pug
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
html
head
title= title
body
h1= message
Loading