Skip to content

Commit 2c84ef6

Browse files
authored
Switched to using the server time for event timestamps (#459)
ref https://linear.app/ghost/issue/NY-344/switch-to-using-server-time-for-analytics-events We currently accept the timestamp in each event received, which is generated from the client's browser, and forward it directly to Tinybird. To avoid issues with browser clock drift, we've decided to always use the server time, since we know it is right and can ensure it is applied consistently. This is also simpler than validating the timestamp and either rejecting or coercing the timestamp for "invalid" timestamps.
1 parent f4ca91e commit 2c84ef6

File tree

5 files changed

+69
-48
lines changed

5 files changed

+69
-48
lines changed

src/app.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import fastify from 'fastify';
33
import {TypeBoxTypeProvider} from '@fastify/type-provider-typebox';
44
import loggingPlugin from './plugins/logging';
5+
import timestampPlugin from './plugins/timestamp';
56
import corsPlugin from './plugins/cors';
67
import proxyPlugin from './plugins/proxy';
78
import hmacValidationPlugin from './plugins/hmac-validation';
@@ -16,28 +17,23 @@ const app = fastify({
1617
trustProxy: process.env.TRUST_PROXY !== 'false'
1718
}).withTypeProvider<TypeBoxTypeProvider>();
1819

19-
// Register global validation error handler
20+
// Global error handler
2021
app.setErrorHandler(errorHandler());
21-
22-
// Register reply-from plugin
2322
app.register(replyFrom);
24-
25-
// Register CORS plugin
2623
app.register(corsPlugin);
27-
28-
// Register logging plugin
2924
app.register(loggingPlugin);
3025

26+
// Record timestamp request was received as early as possible
27+
app.register(timestampPlugin);
28+
3129
// Register HMAC validation plugin (before all other business logic)
3230
app.register(hmacValidationPlugin);
3331

34-
// Register proxy plugin
32+
// Local proxy endpoint for development/testing
3533
app.register(proxyPlugin);
3634

37-
// Register v1 routes
35+
// Register routes
3836
app.register(v1Routes, {prefix: '/api/v1'});
39-
40-
// Routes
4137
app.get('/', async () => {
4238
return 'Hello Ghost Traffic Analytics';
4339
});

src/plugins/timestamp.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {FastifyInstance} from 'fastify';
2+
import fp from 'fastify-plugin';
3+
4+
declare module 'fastify' {
5+
interface FastifyRequest {
6+
serverReceivedAt: Date;
7+
}
8+
}
9+
10+
async function timestampPlugin(fastify: FastifyInstance) {
11+
// Capture timestamp as early as possible in the request lifecycle.
12+
fastify.addHook('onRequest', async (request) => {
13+
request.serverReceivedAt = new Date();
14+
});
15+
}
16+
17+
export default fp(timestampPlugin);

src/transformations/page-hit-transformations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const pageHitRawPayloadFromRequest = (request: PageHitRequestType): PageH
1515
};
1616

1717
return {
18-
timestamp: request.body.timestamp,
18+
timestamp: request.serverReceivedAt.toISOString(),
1919
action: request.body.action,
2020
version: request.body.version,
2121
site_uuid: request.headers['x-site-uuid'],

test/e2e/web_analytics.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,11 @@ describe('E2E Tests with Fake Tinybird', () => {
113113
// Verify the request body was processed and enriched
114114
const requestBody = wireMock.parseRequestBody(tinybirdRequests[0]);
115115

116+
// Verify timestamp is server time (not client time)
117+
expect(requestBody.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
118+
expect(requestBody.timestamp).not.toBe(DEFAULT_BODY.timestamp); // Should be overwritten with server time
119+
116120
expect(requestBody).toMatchObject({
117-
timestamp: DEFAULT_BODY.timestamp,
118121
action: 'page_hit',
119122
version: '1',
120123
// Should have session_id added by processing
@@ -161,8 +164,11 @@ describe('E2E Tests with Fake Tinybird', () => {
161164
// Verify the request body was processed and enriched
162165
const requestBody = wireMock.parseRequestBody(tinybirdRequests[0]);
163166

167+
// Verify timestamp is server time (not client time)
168+
expect(requestBody.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
169+
expect(requestBody.timestamp).not.toBe(DEFAULT_BODY.timestamp); // Should be overwritten with server time
170+
164171
expect(requestBody).toMatchObject({
165-
timestamp: DEFAULT_BODY.timestamp,
166172
action: 'page_hit',
167173
version: '1',
168174
// Should have session_id added by processing

test/unit/transformations/page-hit-transformations.test.ts

Lines changed: 36 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import {pageHitRawPayloadFromRequest} from '../../../src/transformations/page-hi
33
import {PageHitRequestType} from '../../../src/schemas';
44

55
describe('pageHitRawPayloadFromRequest', () => {
6-
function createPageHitRequest() {
6+
function createPageHitRequest(serverReceivedAt: Date = new Date()) {
77
return {
88
ip: '192.168.1.1',
9+
serverReceivedAt,
910
headers: {
1011
'x-site-uuid': '12345678-1234-1234-1234-123456789012',
1112
'content-type': 'application/json',
@@ -37,44 +38,43 @@ describe('pageHitRawPayloadFromRequest', () => {
3738
}
3839

3940
it('should transform request to PageHitRaw with all fields present', () => {
40-
const request = createPageHitRequest();
41+
const serverTime = new Date('2024-01-15T10:30:00.000Z');
42+
const request = createPageHitRequest(serverTime);
4143
const result = pageHitRawPayloadFromRequest(request);
4244

43-
expect(result).toEqual({
44-
timestamp: '2024-01-01T00:00:00.000Z',
45-
action: 'page_hit',
46-
version: '1',
47-
site_uuid: '12345678-1234-1234-1234-123456789012',
48-
payload: {
49-
event_id: 'test-event-id',
50-
member_uuid: 'member-uuid-123',
51-
member_status: 'free',
52-
post_uuid: 'post-uuid-456',
53-
post_type: 'post',
54-
locale: 'en-US',
55-
location: 'homepage',
56-
referrer: 'https://google.com',
57-
parsedReferrer: {
58-
source: 'google',
59-
medium: 'organic',
60-
url: 'https://google.com'
61-
},
62-
pathname: '/blog/post',
63-
href: 'https://example.com/blog/post',
64-
utm_source: null,
65-
utm_medium: null,
66-
utm_campaign: null,
67-
utm_term: null,
68-
utm_content: null,
69-
meta: {
70-
received_timestamp: null
71-
}
45+
expect(result.timestamp).toBe(serverTime.toISOString());
46+
expect(result.action).toBe('page_hit');
47+
expect(result.version).toBe('1');
48+
expect(result.site_uuid).toBe('12345678-1234-1234-1234-123456789012');
49+
expect(result.payload).toEqual({
50+
event_id: 'test-event-id',
51+
member_uuid: 'member-uuid-123',
52+
member_status: 'free',
53+
post_uuid: 'post-uuid-456',
54+
post_type: 'post',
55+
locale: 'en-US',
56+
location: 'homepage',
57+
referrer: 'https://google.com',
58+
parsedReferrer: {
59+
source: 'google',
60+
medium: 'organic',
61+
url: 'https://google.com'
7262
},
63+
pathname: '/blog/post',
64+
href: 'https://example.com/blog/post',
65+
utm_source: null,
66+
utm_medium: null,
67+
utm_campaign: null,
68+
utm_term: null,
69+
utm_content: null,
7370
meta: {
74-
ip: '192.168.1.1',
75-
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
71+
received_timestamp: null
7672
}
7773
});
74+
expect(result.meta).toEqual({
75+
ip: '192.168.1.1',
76+
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
77+
});
7878
});
7979

8080
describe('Event ID', () => {
@@ -287,8 +287,10 @@ describe('pageHitRawPayloadFromRequest', () => {
287287
});
288288

289289
it('should handle complex real-world request', () => {
290+
const serverTime = new Date('2024-03-15T14:30:25.123Z');
290291
const request = {
291292
ip: '203.0.113.42',
293+
serverReceivedAt: serverTime,
292294
headers: {
293295
'x-site-uuid': 'c7929de8-27d7-404e-b714-0fc774f701e6',
294296
'content-type': 'application/json',
@@ -320,7 +322,7 @@ describe('pageHitRawPayloadFromRequest', () => {
320322

321323
const result = pageHitRawPayloadFromRequest(request);
322324

323-
expect(result.timestamp).toBe('2024-03-15T14:30:25.123Z');
325+
expect(result.timestamp).toBe(serverTime.toISOString());
324326
expect(result.action).toBe('page_hit');
325327
expect(result.version).toBe('1');
326328
expect(result.site_uuid).toBe('c7929de8-27d7-404e-b714-0fc774f701e6');

0 commit comments

Comments
 (0)