SailsJS
Learn how to integrate OpenTelemetry with SailsJS applications and send telemetry data to Last9
This guide shows you how to instrument your SailsJS application with OpenTelemetry and send traces, metrics, and logs to Last9.
Prerequisites
- Node.js 14 or later
- SailsJS application
- Last9 account with OTLP endpoint configured
Installation
-
Install OpenTelemetry packages
npm install --save @opentelemetry/api \@opentelemetry/sdk-node \@opentelemetry/auto-instrumentations-node \@opentelemetry/exporter-trace-otlp-http \@opentelemetry/exporter-metrics-otlp-http \@opentelemetry/exporter-logs-otlp-http \@opentelemetry/resources \@opentelemetry/semantic-conventions -
Install additional instrumentation packages (optional)
# For manual instrumentationnpm install --save @opentelemetry/instrumentation-http \@opentelemetry/instrumentation-express \@opentelemetry/instrumentation-redis \@opentelemetry/instrumentation-mongodb \@opentelemetry/instrumentation-mysql \@opentelemetry/instrumentation-winston
Configuration
Set up your environment variables:
export OTEL_EXPORTER_OTLP_ENDPOINT="$last9_otlp_endpoint"export OTEL_EXPORTER_OTLP_HEADERS="Authorization=$last9_otlp_auth_header"export OTEL_SERVICE_NAME="sailsjs-app"export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production,service.version=1.0.0"export NODE_ENV="production"Instrumentation Setup
Auto-Instrumentation (Recommended)
const { NodeSDK } = require("@opentelemetry/sdk-node");const { getNodeAutoInstrumentations,} = require("@opentelemetry/auto-instrumentations-node");const { OTLPTraceExporter,} = require("@opentelemetry/exporter-trace-otlp-http");const { OTLPMetricExporter,} = require("@opentelemetry/exporter-metrics-otlp-http");const { OTLPLogExporter } = require("@opentelemetry/exporter-logs-otlp-http");const { Resource } = require("@opentelemetry/resources");const { SemanticResourceAttributes,} = require("@opentelemetry/semantic-conventions");const { PeriodicExportingMetricReader } = require("@opentelemetry/sdk-metrics");
// Create resource with service informationconst resource = new Resource({ [SemanticResourceAttributes.SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || "sailsjs-app", [SemanticResourceAttributes.SERVICE_VERSION]: process.env.npm_package_version || "1.0.0", [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV || "development",});
// Configure exportersconst traceExporter = new OTLPTraceExporter({ url: `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`, headers: { Authorization: process.env.OTEL_EXPORTER_OTLP_HEADERS?.replace("Authorization=", "") || "", },});
const metricExporter = new OTLPMetricExporter({ url: `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/metrics`, headers: { Authorization: process.env.OTEL_EXPORTER_OTLP_HEADERS?.replace("Authorization=", "") || "", },});
const logExporter = new OTLPLogExporter({ url: `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/logs`, headers: { Authorization: process.env.OTEL_EXPORTER_OTLP_HEADERS?.replace("Authorization=", "") || "", },});
// Initialize the SDKconst sdk = new NodeSDK({ resource, traceExporter, metricReader: new PeriodicExportingMetricReader({ exporter: metricExporter, exportIntervalMillis: 30000, // Export metrics every 30 seconds }), logRecordProcessor: new (require("@opentelemetry/sdk-logs").BatchLogRecordProcessor)( logExporter, ), instrumentations: [ getNodeAutoInstrumentations({ // Disable file system instrumentation as it's too noisy "@opentelemetry/instrumentation-fs": { enabled: false, }, // Configure HTTP instrumentation "@opentelemetry/instrumentation-http": { enabled: true, ignoreIncomingRequestHook: (req) => { // Ignore health check requests return req.url?.includes("/health") || req.url?.includes("/ping"); }, ignoreOutgoingRequestHook: (options) => { // Ignore requests to monitoring endpoints return ( options.hostname?.includes("health") || options.path?.includes("metrics") ); }, }, // Configure Express instrumentation for SailsJS "@opentelemetry/instrumentation-express": { enabled: true, ignoreLayers: [ (layer, request) => { // Ignore static file serving layers return layer.name === "serveStatic"; }, ], }, // Configure database instrumentations "@opentelemetry/instrumentation-mysql": { enabled: true, }, "@opentelemetry/instrumentation-mongodb": { enabled: true, }, "@opentelemetry/instrumentation-redis": { enabled: true, }, // Configure Winston logging if used "@opentelemetry/instrumentation-winston": { enabled: true, }, }), ],});
// Start the SDKtry { sdk.start(); console.log("🚀 OpenTelemetry instrumentation initialized successfully");} catch (error) { console.error("❌ Error initializing OpenTelemetry:", error);}
// Graceful shutdownconst shutdown = async () => { try { await sdk.shutdown(); console.log("📊 OpenTelemetry terminated gracefully"); } catch (error) { console.error("❌ Error terminating OpenTelemetry:", error); } finally { process.exit(0); }};
process.on("SIGTERM", shutdown);process.on("SIGINT", shutdown);
module.exports = sdk;// Import instrumentation FIRST - before any other importsrequire("./instrumentation");
/** * app.js * * Use `app.js` to run your app without `sails lift`. * To start the server, run: `node app.js`. * * This is handy in situations where the sails command line tool isn't relevant * or useful, such as when you deploy to a server, or a PaaS like Heroku. */
// Ensure we're in the project directory, so cwd-relative paths work as expectedprocess.chdir(__dirname);
// Attempt to import `sails` dependency, as well as `rc` (for loading `.sailsrc` files).var sails, rc;try { sails = require("sails"); rc = require("sails/accessible/rc");} catch (err) { console.error("Encountered an error when attempting to require('sails'):"); console.error(err.stack); console.error("--"); console.error( "To run an app using `node app.js`, you need to have sails installed", ); console.error( "locally (`./node_modules/sails`). To do that, run `npm install sails`.", ); console.error("--"); console.error( "Alternatively, if you have sails installed globally, you can use `sails lift`.", ); console.error( "When you run `sails lift`, your app will still use a locally installed version", ); console.error( "of Sails (thanks to the `sails` dependency in your app's package.json file)", ); console.error( "if one exists. If it doesn't, Sails will attempt to use the globally", ); console.error("installed version."); return;}
var conf = rc("sails");
// Start serversails.lift(conf, function (err) { if (err) { console.error("Error occurred lifting Sails app:", err); return; }
console.log("⚓ Sails app lifted successfully!"); console.log("📊 OpenTelemetry monitoring active"); console.log( `🌐 Server running at: http://localhost:${sails.config.port || 1337}`, );});Manual Instrumentation
For more control over your instrumentation, you can add custom spans and metrics:
const { trace, metrics } = require("@opentelemetry/api");
// Get tracer and meter instancesconst tracer = trace.getTracer("sailsjs-app", "1.0.0");const meter = metrics.getMeter("sailsjs-app", "1.0.0");
// Create custom metricsconst httpRequestCount = meter.createCounter("http_requests_total", { description: "Total number of HTTP requests",});
const httpRequestDuration = meter.createHistogram("http_request_duration_ms", { description: "Duration of HTTP requests in milliseconds", unit: "ms",});
const activeConnections = meter.createUpDownCounter("http_active_connections", { description: "Number of active HTTP connections",});
const businessMetrics = { userRegistrations: meter.createCounter("user_registrations_total", { description: "Total number of user registrations", }), ordersCreated: meter.createCounter("orders_created_total", { description: "Total number of orders created", }), cacheHits: meter.createCounter("cache_hits_total", { description: "Total number of cache hits", }), cacheMisses: meter.createCounter("cache_misses_total", { description: "Total number of cache misses", }),};
module.exports = { tracer, meter, metrics: { httpRequestCount, httpRequestDuration, activeConnections, ...businessMetrics, },};// Sails policy for adding custom telemetryconst { trace, context } = require("@opentelemetry/api");const { tracer, metrics } = require("../../config/telemetry");
module.exports = function (req, res, next) { const startTime = Date.now();
// Get current span (created by auto-instrumentation) const currentSpan = trace.getActiveSpan();
if (currentSpan) { // Add custom attributes to the current span currentSpan.setAttributes({ "sails.action": req.options?.action || "unknown", "sails.controller": req.options?.controller || "unknown", "user.id": req.session?.userId || "anonymous", "request.id": req.id || "unknown", }); }
// Track active connections metrics.activeConnections.add(1, { method: req.method, route: req.route?.path || req.path, });
// Wrap the response end to capture metrics const originalEnd = res.end; res.end = function (...args) { const duration = Date.now() - startTime;
// Record metrics metrics.httpRequestCount.add(1, { method: req.method, status_code: res.statusCode.toString(), route: req.route?.path || req.path, });
metrics.httpRequestDuration.record(duration, { method: req.method, status_code: res.statusCode.toString(), route: req.route?.path || req.path, });
// Decrease active connections metrics.activeConnections.add(-1, { method: req.method, route: req.route?.path || req.path, });
// Add response size to span if available if (currentSpan && res.get("content-length")) { currentSpan.setAttributes({ "http.response.size": parseInt(res.get("content-length")), }); }
return originalEnd.apply(this, args); };
return next();};Custom Service Instrumentation
Create instrumented services for your business logic:
const { tracer, metrics } = require("../../config/telemetry");const { trace, SpanStatusCode } = require("@opentelemetry/api");
module.exports = { async createUser(userData) { return tracer.startActiveSpan("user_service.create_user", async (span) => { try { span.setAttributes({ "user.email": userData.email, "user.role": userData.role || "user", operation: "create_user", });
// Validate user data const validationSpan = tracer.startSpan("user_service.validate_data"); const validation = await this._validateUserData(userData); validationSpan.setAttributes({ "validation.passed": validation.isValid, "validation.errors_count": validation.errors?.length || 0, }); validationSpan.end();
if (!validation.isValid) { throw new Error(`Validation failed: ${validation.errors.join(", ")}`); }
// Check if user already exists const existsSpan = tracer.startSpan("user_service.check_exists"); const existingUser = await User.findOne({ email: userData.email }); existsSpan.setAttributes({ "user.exists": !!existingUser, }); existsSpan.end();
if (existingUser) { throw new Error("User already exists"); }
// Create user in database const dbSpan = tracer.startSpan("user_service.db_create"); const newUser = await User.create(userData).fetch(); dbSpan.setAttributes({ "user.created_id": newUser.id, "db.operation": "INSERT", "db.table": "users", }); dbSpan.end();
// Send welcome email const emailSpan = tracer.startSpan("user_service.send_welcome_email"); try { await EmailService.sendWelcomeEmail(newUser); emailSpan.setAttributes({ "email.sent": true, "email.type": "welcome", }); } catch (emailError) { emailSpan.recordException(emailError); emailSpan.setStatus({ code: SpanStatusCode.ERROR, message: "Failed to send welcome email", }); // Don't fail user creation if email fails sails.log.warn("Failed to send welcome email:", emailError); } emailSpan.end();
// Record business metric metrics.userRegistrations.add(1, { "user.role": userData.role || "user", "registration.source": userData.source || "direct", });
span.setAttributes({ "user.created": true, "user.id": newUser.id, });
return newUser; } catch (error) { span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message, }); throw error; } }); },
async _validateUserData(userData) { const errors = [];
if (!userData.email || !userData.email.includes("@")) { errors.push("Invalid email"); }
if (!userData.name || userData.name.length < 2) { errors.push("Name must be at least 2 characters"); }
if (!userData.password || userData.password.length < 8) { errors.push("Password must be at least 8 characters"); }
return { isValid: errors.length === 0, errors: errors, }; },
async getUserById(userId) { return tracer.startActiveSpan("user_service.get_by_id", async (span) => { span.setAttributes({ "user.id": userId, operation: "get_user", });
try { // Try cache first const cacheSpan = tracer.startSpan("user_service.cache_lookup"); const cachedUser = await CacheService.get(`user:${userId}`); cacheSpan.setAttributes({ "cache.hit": !!cachedUser, "cache.key": `user:${userId}`, }); cacheSpan.end();
if (cachedUser) { metrics.cacheHits.add(1, { type: "user" }); span.setAttributes({ "cache.hit": true }); return JSON.parse(cachedUser); }
metrics.cacheMisses.add(1, { type: "user" });
// Fetch from database const dbSpan = tracer.startSpan("user_service.db_fetch"); const user = await User.findOne({ id: userId }); dbSpan.setAttributes({ "db.operation": "SELECT", "db.table": "users", "user.found": !!user, }); dbSpan.end();
if (user) { // Cache for future requests const cacheSetSpan = tracer.startSpan("user_service.cache_set"); await CacheService.set(`user:${userId}`, JSON.stringify(user), 300); // 5 minutes cacheSetSpan.setAttributes({ "cache.key": `user:${userId}`, "cache.ttl": 300, }); cacheSetSpan.end(); }
span.setAttributes({ "user.found": !!user, "cache.hit": false, });
return user; } catch (error) { span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message, }); throw error; } }); },};const { tracer } = require("../../config/telemetry");const { SpanStatusCode } = require("@opentelemetry/api");
module.exports = { async get(key) { return tracer.startActiveSpan("cache_service.get", async (span) => { span.setAttributes({ "cache.key": key, "cache.operation": "GET", });
try { // Assuming you're using Redis or similar cache const result = await sails.cache.get(key);
span.setAttributes({ "cache.hit": !!result, "cache.result_size": result ? result.length : 0, });
return result; } catch (error) { span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message, }); throw error; } }); },
async set(key, value, ttl = 3600) { return tracer.startActiveSpan("cache_service.set", async (span) => { span.setAttributes({ "cache.key": key, "cache.operation": "SET", "cache.ttl": ttl, "cache.value_size": value ? value.length : 0, });
try { const result = await sails.cache.set(key, value, ttl);
span.setAttributes({ "cache.set_success": !!result, });
return result; } catch (error) { span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message, }); throw error; } }); },
async del(key) { return tracer.startActiveSpan("cache_service.delete", async (span) => { span.setAttributes({ "cache.key": key, "cache.operation": "DELETE", });
try { const result = await sails.cache.del(key);
span.setAttributes({ "cache.delete_success": !!result, });
return result; } catch (error) { span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message, }); throw error; } }); },};Controller Examples
const { tracer, metrics } = require("../../config/telemetry");const { trace, SpanStatusCode } = require("@opentelemetry/api");
module.exports = { async create(req, res) { const span = trace.getActiveSpan();
if (span) { span.setAttributes({ controller: "UserController", action: "create", "request.body_size": JSON.stringify(req.body || {}).length, }); }
try { const userData = req.body;
// Validate required fields if (!userData.email || !userData.name) { return res.badRequest({ error: "Email and name are required", code: "MISSING_REQUIRED_FIELDS", }); }
// Create user using instrumented service const newUser = await UserService.createUser(userData);
if (span) { span.setAttributes({ "user.created": true, "user.id": newUser.id, }); }
return res.status(201).json({ message: "User created successfully", user: { id: newUser.id, name: newUser.name, email: newUser.email, }, }); } catch (error) { if (span) { span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message, }); }
sails.log.error("Error creating user:", error);
if (error.message.includes("already exists")) { return res.status(409).json({ error: "User already exists", code: "USER_EXISTS", }); }
return res.serverError({ error: "Failed to create user", code: "USER_CREATION_FAILED", }); } },
async findOne(req, res) { const span = trace.getActiveSpan(); const userId = req.param("id");
if (span) { span.setAttributes({ controller: "UserController", action: "findOne", "user.id": userId, }); }
try { if (!userId) { return res.badRequest({ error: "User ID is required", code: "MISSING_USER_ID", }); }
const user = await UserService.getUserById(userId);
if (!user) { if (span) { span.setAttributes({ "user.found": false }); } return res.notFound({ error: "User not found", code: "USER_NOT_FOUND", }); }
if (span) { span.setAttributes({ "user.found": true, "user.role": user.role, }); }
return res.json({ user: { id: user.id, name: user.name, email: user.email, role: user.role, createdAt: user.createdAt, }, }); } catch (error) { if (span) { span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message, }); }
sails.log.error("Error fetching user:", error);
return res.serverError({ error: "Failed to fetch user", code: "USER_FETCH_FAILED", }); } },
async find(req, res) { const span = trace.getActiveSpan();
if (span) { span.setAttributes({ controller: "UserController", action: "find", }); }
try { const { limit = 50, skip = 0, role } = req.query;
// Build query criteria const criteria = {}; if (role) { criteria.role = role; }
if (span) { span.setAttributes({ "query.limit": parseInt(limit), "query.skip": parseInt(skip), "query.has_role_filter": !!role, }); }
// Create child span for database operation const users = await tracer.startActiveSpan( "user_controller.db_find", async (dbSpan) => { dbSpan.setAttributes({ "db.operation": "SELECT", "db.table": "users", "db.limit": parseInt(limit), "db.skip": parseInt(skip), });
try { const results = await User.find(criteria) .limit(parseInt(limit)) .skip(parseInt(skip)) .sort("createdAt DESC");
dbSpan.setAttributes({ "db.results_count": results.length, });
return results; } catch (error) { dbSpan.recordException(error); dbSpan.setStatus({ code: SpanStatusCode.ERROR, message: error.message, }); throw error; } }, );
if (span) { span.setAttributes({ "users.count": users.length, "users.has_results": users.length > 0, }); }
return res.json({ users: users.map((user) => ({ id: user.id, name: user.name, email: user.email, role: user.role, createdAt: user.createdAt, })), pagination: { limit: parseInt(limit), skip: parseInt(skip), count: users.length, }, }); } catch (error) { if (span) { span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message, }); }
sails.log.error("Error listing users:", error);
return res.serverError({ error: "Failed to list users", code: "USER_LIST_FAILED", }); } },};Database Integration
Waterline ORM Hooks
const { tracer } = require("./telemetry");const { SpanStatusCode } = require("@opentelemetry/api");
module.exports.models = { migrate: "safe", attributes: { createdAt: { type: "number", autoCreatedAt: true }, updatedAt: { type: "number", autoUpdatedAt: true }, id: { type: "number", autoIncrement: true, columnName: "id" }, },
// Add lifecycle callbacks for automatic instrumentation beforeCreate: function (valuesToSet, proceed) { return tracer.startActiveSpan("waterline.before_create", (span) => { span.setAttributes({ "db.operation": "BEFORE_CREATE", "db.model": this.globalId || this.identity, });
return proceed(); }); },
afterCreate: function (newlyInsertedRecord, proceed) { return tracer.startActiveSpan("waterline.after_create", (span) => { span.setAttributes({ "db.operation": "AFTER_CREATE", "db.model": this.globalId || this.identity, "db.record_id": newlyInsertedRecord.id, });
return proceed(); }); },
beforeUpdate: function (valuesToSet, proceed) { return tracer.startActiveSpan("waterline.before_update", (span) => { span.setAttributes({ "db.operation": "BEFORE_UPDATE", "db.model": this.globalId || this.identity, });
return proceed(); }); },
afterUpdate: function (updatedRecord, proceed) { return tracer.startActiveSpan("waterline.after_update", (span) => { span.setAttributes({ "db.operation": "AFTER_UPDATE", "db.model": this.globalId || this.identity, "db.record_id": updatedRecord.id, });
return proceed(); }); },
beforeDestroy: function (criteria, proceed) { return tracer.startActiveSpan("waterline.before_destroy", (span) => { span.setAttributes({ "db.operation": "BEFORE_DESTROY", "db.model": this.globalId || this.identity, });
return proceed(); }); },
afterDestroy: function (destroyedRecords, proceed) { return tracer.startActiveSpan("waterline.after_destroy", (span) => { span.setAttributes({ "db.operation": "AFTER_DESTROY", "db.model": this.globalId || this.identity, "db.destroyed_count": destroyedRecords.length, });
return proceed(); }); },};Testing the Integration
-
Start your application
npm start# ornode app.js -
Make test requests
# Health check (if you have one)curl http://localhost:1337/health# User endpointscurl http://localhost:1337/api/userscurl http://localhost:1337/api/users/1# Create a usercurl -X POST http://localhost:1337/api/users \-H "Content-Type: application/json" \-d '{"name": "Test User", "email": "test@example.com"}' -
View telemetry in Last9
Check your Last9 dashboard for:
- HTTP request traces with SailsJS-specific attributes
- Database operation spans from Waterline ORM
- Custom business logic spans
- Cache operation traces
- Custom metrics for user registrations and other business events
Production Deployment
Environment Configuration
module.exports = { port: process.env.PORT || 1337,
// Database configuration datastores: { default: { adapter: "sails-postgresql", url: process.env.DATABASE_URL, ssl: process.env.DATABASE_SSL === "true", }, },
// Custom middleware configuration for production http: { middleware: { order: [ "cookieParser", "session", "bodyParser", "compress", "telemetry", // Add telemetry policy ], }, },
// Security configurations security: { cors: { allRoutes: true, allowOrigins: process.env.ALLOWED_ORIGINS?.split(",") || [ "http://localhost:3000", ], allowCredentials: false, }, },
// Session configuration for production session: { secret: process.env.SESSION_SECRET || "your-session-secret", adapter: process.env.SESSION_ADAPTER || "memory", },
// Logging configuration log: { level: process.env.LOG_LEVEL || "info", },
// Telemetry-specific configurations telemetry: { enabled: true, serviceName: process.env.OTEL_SERVICE_NAME || "sailsjs-app", environment: process.env.NODE_ENV || "production", },};FROM node:18-alpine
# Set working directoryWORKDIR /app
# Install curl for health checksRUN apk add --no-cache curl
# Copy package filesCOPY package*.json ./
# Install dependenciesRUN npm ci --only=production
# Copy application codeCOPY . .
# Create a non-root userRUN addgroup -g 1001 -S nodejsRUN adduser -S sailsjs -u 1001
# Change ownership of the app directoryRUN chown -R sailsjs:nodejs /appUSER sailsjs
# Expose portEXPOSE 1337
# Health checkHEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ CMD curl -f http://localhost:1337/health || exit 1
# Start applicationCMD ["node", "app.js"]version: "3.8"
services: sailsjs-app: build: . ports: - "1337:1337" environment: - NODE_ENV=production - OTEL_EXPORTER_OTLP_ENDPOINT=$last9_otlp_endpoint - OTEL_EXPORTER_OTLP_HEADERS=Authorization=$last9_otlp_auth_header - OTEL_SERVICE_NAME=sailsjs-app - OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,service.version=1.0.0 - DATABASE_URL=postgresql://user:pass@postgres:5432/sailsjs_db - REDIS_URL=redis://redis:6379 - SESSION_SECRET=your-production-session-secret depends_on: - postgres - redis healthcheck: test: ["CMD", "curl", "-f", "http://localhost:1337/health"] interval: 30s timeout: 10s retries: 3
postgres: image: postgres:15-alpine environment: POSTGRES_DB: sailsjs_db POSTGRES_USER: user POSTGRES_PASSWORD: pass ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data
redis: image: redis:7-alpine ports: - "6379:6379" volumes: - redis_data:/data
volumes: postgres_data: redis_data: