Juniper supports multiple logging approaches, from simple console logging to comprehensive observability with OpenTelemetry. You can use Hono's built-in logger middleware for request logging and Deno's native OpenTelemetry integration for traces, metrics, and logs.
The simplest way to add request logging is with Hono's built-in logger middleware:
// routes/main.ts
import { Hono } from "hono";
import { logger } from "hono/logger";
const app = new Hono();
// Log all requests
app.use(logger());
export default app;This logs each request with method, path, status code, and response time:
<-- GET /
--> GET / 200 12ms
<-- GET /api/users
--> GET /api/users 200 45ms
Create a custom logger for more control:
import { Hono } from "hono";
const app = new Hono();
app.use("*", async (c, next) => {
const start = performance.now();
console.log(`[${new Date().toISOString()}] ${c.req.method} ${c.req.path}`);
await next();
const duration = Math.round(performance.now() - start);
console.log(
`[${
new Date().toISOString()
}] ${c.req.method} ${c.req.path} ${c.res.status} ${duration}ms`,
);
});
export default app;Use standard console methods for simple logging:
// Log levels
console.log("Info message");
console.debug("Debug message");
console.warn("Warning message");
console.error("Error message");
// Structured logging with objects
console.log("User action:", { userId: "123", action: "login" });Log based on environment:
import { isDevelopment } from "@udibo/juniper/utils/env";
function debugLog(...args: unknown[]) {
if (isDevelopment()) {
console.debug("[DEBUG]", ...args);
}
}
debugLog("Loader executed", { params });Deno has built-in OpenTelemetry support that automatically instruments
console.log, Deno.serve, and fetch calls.
Enable OpenTelemetry with the OTEL_DENO environment variable:
# Run with OpenTelemetry enabled
OTEL_DENO=true deno run --allow-net --allow-env server.ts
# Or use environment variables in your .env file
OTEL_DENO=true
OTEL_SERVICE_NAME=my-juniper-appConfigure the Deno task in your deno.json:
{
"tasks": {
"dev": {
"description": "Runs the development server with OTEL.",
"command": "export OTEL_DENO=true && export OTEL_SERVICE_NAME=dev && deno run -P=dev --env-file @udibo/juniper/dev --project-root ."
}
}
}With OTEL_DENO=true, Deno automatically exports:
- Traces from
Deno.serve()HTTP requests - Traces from
fetch()calls - Logs from
console.log()and other console methods
By default, telemetry is exported to localhost:4318 using OTLP over HTTP.
Use Juniper's otelUtils for simple tracing:
// utils/otel.ts
import { otelUtils } from "@udibo/juniper/utils/otel";
const { startActiveSpan } = otelUtils();
export { startActiveSpan };Wrap operations in spans:
// services/user.ts
import { startActiveSpan } from "@/utils/otel.ts";
export class UserService {
async getUser(id: string) {
return startActiveSpan("user.get", async (span) => {
span.setAttribute("user.id", id);
const user = await db.get(["users", id]);
if (!user) {
span.setAttribute("user.found", false);
throw new HttpError(404, "User not found");
}
span.setAttribute("user.found", true);
return user;
});
}
async createUser(data: NewUser) {
return startActiveSpan("user.create", async (span) => {
span.setAttribute("user.email", data.email);
const user = await db.create(data);
span.setAttribute("user.id", user.id);
return user;
});
}
}Spans with options:
import { SpanKind } from "@opentelemetry/api";
startActiveSpan(
"external-api-call",
{
kind: SpanKind.CLIENT,
attributes: { "http.url": "https://api.example.com" },
},
async (span) => {
const response = await fetch("https://api.example.com/data");
span.setAttribute("http.status_code", response.status);
return response.json();
},
);Use the OpenTelemetry API for custom metrics:
import { metrics } from "@opentelemetry/api";
const meter = metrics.getMeter("my-app");
// Create a counter
const requestCounter = meter.createCounter("app.requests", {
description: "Number of requests processed",
});
// Create a histogram
const requestDuration = meter.createHistogram("app.request.duration", {
description: "Request duration in milliseconds",
unit: "ms",
});
// Use in your code
app.use("*", async (c, next) => {
const start = performance.now();
await next();
const duration = performance.now() - start;
requestCounter.add(1, { path: c.req.path, status: c.res.status });
requestDuration.record(duration, { path: c.req.path });
});Configure OpenTelemetry with environment variables:
# Enable OpenTelemetry
OTEL_DENO=true
# Service identification
OTEL_SERVICE_NAME=my-juniper-app
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,service.version=1.0.0
# Endpoint configuration (defaults to localhost:4318)
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# Console log behavior
# "capture" (default) - export as logs AND print to console
# "replace" - export as logs, don't print to console
# "ignore" - don't export, only print to console
OTEL_DENO_CONSOLE=captureFor local development, use Grafana's LGTM stack (Loki, Grafana, Tempo, Mimir) to collect and visualize telemetry data.
Create a docker-compose.yml file in your project root with the Grafana LGTM
stack:
services:
lgtm:
image: docker.io/grafana/otel-lgtm:0.8.1
container_name: lgtm
ports:
- "3000:3000" # Grafana UI
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
volumes:
- ./docker/volumes/lgtm/grafana:/data/grafana
- ./docker/volumes/lgtm/prometheus:/data/prometheus
- ./docker/volumes/lgtm/loki:/data/loki
environment:
- GF_PATHS_DATA=/data/grafana
restart: unless-stopped
tty: true
stdin_open: trueAdd the docker/volumes/ directory to your .gitignore:
docker/volumes/
Start the stack:
docker compose up -d --wait lgtmThis provides:
- Grafana at http://localhost:3000 (login: admin/admin)
- OpenTelemetry Collector accepting OTLP data
- Loki for logs
- Tempo for traces
- Mimir for metrics
- Open Grafana at http://localhost:3000
- Log in with username
adminand passwordadmin - Go to Explore in the left sidebar
- Select Tempo as the data source
- Use the Search tab to find traces by service name or trace ID
To view a specific trace:
- Run your Juniper app with
OTEL_DENO=true - Make some requests to your application
- In Grafana Explore, search for traces from your service
- Click on a trace to see the span waterfall
Logs (Loki):
- In Grafana Explore, select Loki as the data source
- Use LogQL queries like
{service_name="my-juniper-app"} - View console output from your application
Metrics (Mimir):
- Select Mimir or Prometheus as the data source
- Use PromQL queries to explore metrics
- Create dashboards for key performance indicators
For convenience, add tasks to your deno.json to start and stop the LGTM stack:
{
"tasks": {
"lgtm:start": {
"description": "Starts the LGTM service.",
"command": "docker compose up -d --wait lgtm"
},
"lgtm:stop": {
"description": "Stops the LGTM service.",
"command": "docker compose down lgtm"
}
}
}Then start the LGTM stack and run your dev server with OpenTelemetry enabled:
# Start LGTM stack (waits until ready)
deno task lgtm:start
# Run dev server with OTEL in a separate terminal
deno task devEnsure your dev task has OTEL_DENO=true configured to enable telemetry export.
Stop the LGTM stack when done:
deno task lgtm:stopTo also remove persistent data, delete the volumes directory:
rm -rf docker/volumes/lgtm- Use structured logging - Include context as objects rather than string interpolation
- Use appropriate log levels -
debugfor development,infofor important events,errorfor failures - Don't log sensitive data - Avoid logging passwords, tokens, or PII
- Add meaningful span names - Use descriptive names like
user.createinstead ofcreate - Set span attributes - Add relevant context like IDs, counts, and durations
Next: CI/CD - GitHub Actions workflows
Related topics:
- Error Handling - Error boundaries and HttpError
- Deployment - Deploy to Deno Deploy, Docker, and more