Strapi"s modular plugin system empowers developers to extend its capabilities beyond content management by integrating custom workflows and specialized data handling. Video analytics require tailored tracking and aggregation of user engagement metrics (such as play events, watch durations, completions) that native CMS features don"t provide out of the box.

Embedding video analytics directly within Strapi via custom plugins enables unified data management, real-time insights, and secure, role-based access to video performance metrics essential for optimizing content strategy and user experience.

Prerequisites

  • Install Node.js (v14+), Strapi (v4+), and an SQL-based database such as SQLite for dev or PostgreSQL for production environments.
  • Set up the initial Strapi project by running the installation command and creating a video content type with fields such as title (string) and file (media) using strapi generate:content-type video --field title:string --field file:media.
  • Configure the media library in Strapi to handle video uploads and expose basic API endpoints for video retrieval.
Video Analytics

Creating the Custom Video Analytics Plugin

Generate the plugin structure by running npx strapi generate:plugin video-analytics. In your Strapi project root, execute this command to create the plugin scaffold under src/plugins/video-analytics. This generates directories for admin, api, config, controllers, models, routes, and services. Restart the development server (npm run develop) to register the plugin, and verify it appears in the admin panel under Plugins (if not, run npm run strapi build for safety).

Define the models within the plugin by creating a VideoMetric schema with fields such as videoId (relation to video), userId (UID), eventType (enumeration for play/pause/etc.), timestamp (datetime), and duration (float). Edit src/plugins/video-analytics/models/VideoMetric.settings.json to include:

code
{
"kind": "collectionType",
"collectionName": "video_metrics",
"info": { "singularName": "video-metric", "pluralName": "video-metrics", "displayName": "Video Metric" },
"options": { "draftAndPublish": false },
"pluginOptions": {},
"attributes": {
"videoId": { "type": "relation", "relation": "manyToOne", "target": "api::video.video", "inversedBy": "metrics" },
"userId": { "type": "uid" },
"eventType": { "type": "enumeration", "enum": ["play", "pause", "seek", "end", "view"] },
"timestamp": { "type": "datetime" },
"duration": { "type": "float" }
}
}

Run npm run develop again to sync the model with the database (or use npm run strapi build for production-like validation).

Implement controllers and services by adding API routes like POST /video-analytics/track for logging events and writing service logic to aggregate data using Knex queries for operations like sums and averages. In src/plugins/video-analytics/routes/video-analytics.js, define:

code
module.exports = {
routes: [
{ method: 'POST', path: '/video-analytics/track', handler: 'video-analytics.track', config: { policies: [] } },
{ method: 'GET', path: '/video-analytics/:id/summary', handler: 'video-analytics.summary', config: { policies: [] } }
]
};

For the controller (src/plugins/video-analytics/controllers/video-analytics.js):

code
'use strict';
const { createCoreController } = require('@strapi/strapi').factories;
const { sanitizeEntity } = require('strapi-utils');

module.exports = createCoreController('plugin::video-analytics.video-metric', ({ strapi }) => ({
async track(ctx) {
try {
const { videoId, userId, eventType, duration } = ctx.request.body;
// Validation (detailed in next section)
if (!['play', 'pause', 'seek', 'end', 'view'].includes(eventType) || !videoId || !userId) {
return ctx.badRequest('Invalid event data');
}
// Rate limiting (detailed in next section)
const recentEvents = await strapi.entityService.findMany('plugin::video-analytics.video-metric', {
filters: { userId, timestamp: { gt: new Date(Date.now() - 60000).toISOString() } },
_limit: 10
});
if (recentEvents.length >= 10) return ctx.badRequest('Rate limit exceeded');

const metric = await strapi.entityService.create('plugin::video-analytics.video-metric', {
data: { videoId, userId, eventType, timestamp: new Date().toISOString(), duration }
});
ctx.body = { success: true, id: metric.id };
} catch (error) {
ctx.badRequest('Error tracking event', { error: error.message });
}
},
async summary(ctx) {
try {
const { id } = ctx.params;
const { startDate, endDate, eventType } = ctx.query;
const filters = { videoId: id };
if (startDate) filters.timestamp = { gte: new Date(startDate).toISOString() };
if (endDate) filters.timestamp = { ...filters.timestamp, lte: new Date(endDate).toISOString() };
if (eventType) filters.eventType = eventType;

const metrics = await strapi.entityService.findMany('plugin::video-analytics.video-metric', {
filters,
populate: ['videoId'],
sort: { timestamp: 'desc' }
});
const avgWatchTime = await strapi.plugin('video-analytics').service('video-metric').calculateAverageWatchTime(id);
ctx.body = { metrics: metrics.map(entity => sanitizeEntity(entity, { model: strapi.getModel('plugin::video-analytics.video-metric') }) || [], averageWatchTime };
} catch (error) {
ctx.badRequest('Error fetching summary', { error: error.message });
}
}
}));

Create a service in src/plugins/video-analytics/services/video-metric.js for reusable logic, e.g., aggregation functions (note: service name matches model plural):

code
'use strict';
const { createCoreService } = require('@strapi/strapi').factories;

module.exports = createCoreService('plugin::video-analytics.video-metric', ({ strapi }) => ({
async calculateAverageWatchTime(videoId) {
const db = strapi.db.connection;
const result = await db('video_metrics')
.where({ videoId, eventType: 'end' })
.avg('duration as avg_duration')
.first();
return parseFloat(result.avg_duration) || 0;
}
}));

Integrate middleware by hooking into Strapi's request lifecycle to enable automatic tracking on video access events. In src/plugins/video-analytics/middlewares/track-video.js, define:

code
module.exports = (config, { strapi }) => {
return async (ctx, next) => {
// Match single video GET requests (e.g., /api/videos/1)
if (ctx.path.match(/^\/api\/videos\/\d+$/) && ctx.method === 'GET') {
const videoId = ctx.path.split('/').pop(); // Extract ID from path
const userId = ctx.state.user?.id || 'anonymous'; // From JWT if authenticated
try {
await strapi.entityService.create('plugin::video-analytics.video-metric', {
data: { videoId, userId, eventType: 'view', timestamp: new Date().toISOString() }
});
} catch (error) {
strapi.log.warn('Failed to log view event:', error);
}
}
await next();
};
};

Register it in src/plugins/video-analytics/config/middlewares.js: module.exports = { TrackVideo: true }; (this enables it for the plugin; for global use, add to the main app's config/middlewares.js as module.exports = ['plugin::video-analytics.track-video'];).

Implementing Analytics Tracking

Integrate on the frontend by adding JavaScript event listeners, such as video.addEventListener('timeupdate', sendToStrapi) to capture and transmit video events. In your frontend app (e.g., a Vue or React component), use refs for scoping and debounce frequent events like timeupdate (e.g., every 5 seconds) to avoid overload.

Example in Vanilla JS or Framework-Agnostic:

code
// Debounce utility
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => { clearTimeout(timeout); func(...args); };
clearTimeout(timeout); timeout = setTimeout(later, wait);
};
}

const video = document.querySelector('video'); // Or use ref in Vue/React
const strapiUrl = 'http://localhost:1337';
const token = 'your-jwt-token'; // From auth, if protected

const sendEvent = debounce(async (eventType, duration = 0) => {
try {
await fetch(`${strapiUrl}/api/video-analytics/track`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}` // If endpoints require auth
},
body: JSON.stringify({
videoId: 'your-video-id', // From props, URL, or data attribute
userId: 'user-123', // From auth session
eventType,
duration
})
});
} catch (error) {
console.error('Tracking failed:', error);
}
}, 5000); // Debounce 5s for timeupdate

video.addEventListener('play', () => sendEvent('play'));
video.addEventListener('pause', () => sendEvent('pause'));
video.addEventListener('timeupdate', () => sendEvent('seek', video.currentTime));
video.addEventListener('ended', () => sendEvent('end', video.duration));

Process on the backend by validating incoming events in the plugin controller, storing them in the database, and applying rate limiting to prevent abuse. The track method above includes validation (enum check, required fields) and basic rate limiting (last-minute events per user). For advanced rate limiting, install @strapi/plugin-users-permissions for auth and use a dedicated plugin like strapi-plugin-rate-limit. Sanitize inputs with strapi.utils.sanitize if extending validation.

Develop querying endpoints by creating custom API routes like GET /video-analytics/:id/summary that support filters for date ranges and metric types. The summary method above handles this with corrected filters (e.g., gte/lte operators, ISO strings) and populates relations. It integrates the service for average watch time and sanitizes output for security.

Include example code snippets such as a plugin controller method for event ingestion and a service function for calculating metrics like average watch time. See the controller track method and service calculateAverageWatchTime above. To call the service in other parts: const avg = await strapi.plugin('video-analytics').service('video-metric').calculateAverageWatchTime(id);.

Visualization and Admin Extensions

Extend the admin panel by adding a dashboard widget using React components and integrating Chart.js to render graphs for analytics data. Install Chart.js in the project root (npm install chart.js react-chartjs-2). In src/plugins/video-analytics/admin/src/components/VideoAnalyticsDashboard/index.js, create:

code
import React, { useEffect, useState } from 'react';
import { Bar } from 'react-chartjs-2';
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement } from 'chart.js';
import { request } from '@strapi/helper-plugin'; // Strapi's request util for auth

ChartJS.register(CategoryScale, LinearScale, BarElement);

const VideoAnalyticsDashboard = ({ videoId = 1 }) => { // Make dynamic
const [data, setData] = useState({ labels: [], datasets: [] });
const [loading, setLoading] = useState(true);

useEffect(() => {
const fetchData = async () => {
try {
const summary = await request(`/video-analytics/${videoId}/summary`, { method: 'GET' }); // Auto-includes auth token
const labels = [...new Set(summary.metrics.map(m => m.eventType))]; // Unique events
const counts = {};
summary.metrics.forEach(m => {
counts[m.eventType] = (counts[m.eventType] || 0) + 1;
});
setData({
labels,
datasets: [{ label: 'Event Counts', data: labels.map(label => counts[label]), backgroundColor: 'rgba(75,192,192,0.6)' }]
});
} catch (error) {
console.error('Failed to fetch analytics:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, [videoId]);

if (loading) return <div>Loading analytics...</div>;
return <Bar data={data} options={{ responsive: true }} />;
};

export default VideoAnalyticsDashboard;

Register it in src/plugins/video-analytics/admin/src/index.js by injecting into the admin app (e.g., add a menu link):

code
export default {
register(app) {
app.injectContentManagerComponent('list', 'video', {
name: 'video-analytics',
Component: async () => import('./components/VideoAnalyticsDashboard')
});
// Or for a custom menu: app.addMenuLink({ to: '/plugins/video-analytics', icon: VideoIcon, intlLabel: { id: 'video-analytics.menu', defaultMessage: 'Video Analytics' }, Component: () => import('./pages/App') });
},
bootstrap(app) {}
};

Expose data for external use by providing aggregated analytics through GraphQL queries or REST endpoints. Install the GraphQL plugin (npm run strapi install graphql), which auto-generates schemas from your models. Query example (via GraphQL playground at /graphql): { videoMetrics(filters: { videoId: { eq: "1" } }) { data { attributes { eventType duration timestamp } } } }. For custom resolvers, extend in src/plugins/video-analytics/server/graphql/resolvers.js if needed.

Secure access by implementing role-based controls, such as restricting full analytics views to admin users only. In the admin panel, go to Settings > Users & Permissions > Roles > Authenticated (or create a custom role), and enable permissions for the plugin's routes (e.g., find → video-analytics → under Application > video-metric and toggle Create/Read/Update). For finer control, add policies in routes: config: { policies: ['global::is-admin'] }. Define the policy in src/policies/is-admin.js:

code
module.exports = async (ctx, next) => {
if (ctx.state.user?.role?.type !== 'admin') {
return ctx.unauthorized('Admin access required');
}
return await next();
};

Testing and Deployment

Write unit and integration tests using Jest to cover services and controllers, including mocks for video events. Install Jest (npm install --save-dev jest @strapi/test-utils), add "test": "jest" to package.json, then create src/plugins/video-analytics/__tests__/video-metric.test.js (match service name):

code
const { createStrapiInstance } = require('@strapi/test-utils');

describe('Video Metric Service', () => {
let strapi;

beforeAll(async () => {
strapi = await createStrapiInstance({
dir: __dirname,
env: 'test',
autoReload: true
});
await strapi.load();
});

afterAll(async () => {
await strapi.stop();
});

test('calculates average watch time', async () => {
// Mock DB query if needed, or seed test data
const service = strapi.plugin('video-analytics').service('video-metric');
const avg = await service.calculateAverageWatchTime('test-video-id');
expect(avg).toBeGreaterThanOrEqual(0);
});

test('tracks event (mocked)', async () => {
const mockCreate = jest.spyOn(strapi.entityService, 'create').mockResolvedValue({ id: 1 });
const service = strapi.plugin('video-analytics').service('video-metric');
const result = await service.create({ data: { eventType: 'play', videoId: '1', userId: 'user-1' } });
expect(mockCreate).toHaveBeenCalled();
expect(result).toHaveProperty('id');
mockCreate.mockRestore();
});
});

Run with npm test. For integration, use Strapi's test DB and seed video metrics.

Address performance by indexing relevant database fields and implementing query caching with tools like Redis. Use Strapi's migration system: Create src/database/migrations/20230101-add-index.js with module.exports = { async up(queryInterface) { await queryInterface.sequelize.query('CREATE INDEX idx_video_metrics_videoId ON video_metrics (videoId);'); } }; and run npm run strapi db:migrate. For caching, install Redis (npm install redis), create a client in a service (e.g., const redis = require('redis').createClient({ url: process.env.REDIS_URL }); await redis.connect();), and wrap queries:

code
async getCachedSummary(key, callback)