Skip to content

Commit 9b412fd

Browse files
feat(analytics): add PostHog tracking for websocket connections, messages and shared-state updates
- import and initialize PostHogService on Connection - add emitAnalytics helper and wire into lifecycle hooks - track connection open/close events (ws_connection_opened / ws_connection_closed) with socket counts, object id and reason - add richer message tracking (ws_message) including STATE_UPDATE, STOPBAR_CROSSING and SHARED_STATE_UPDATE metadata - propagate disconnect reasons into trackDisconnection calls - compute shared-state patch key/count and size limits and include in analytics - update trackConnection signature to accept airport
1 parent 7880a82 commit 9b412fd

1 file changed

Lines changed: 99 additions & 17 deletions

File tree

src/network/connection.ts

Lines changed: 99 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { PointsService } from '../services/points';
55
import { IDService } from '../services/id';
66
import { DivisionService } from '../services/divisions';
77
import { DatabaseContextFactory } from '../services/database-context';
8+
import { PostHogService } from '../services/posthog';
89

910
const MAX_STATE_SIZE = 1000000; // 1MB limit for persisted payloads
1011

@@ -105,6 +106,7 @@ export class Connection {
105106
pilots: 0,
106107
observers: 0,
107108
};
109+
private posthog: PostHogService;
108110
private lastKnownAirport = 'unknown';
109111

110112
constructor(
@@ -114,6 +116,7 @@ export class Connection {
114116
private state: DurableObjectState,
115117
) {
116118
this.objectId = state.id.toString();
119+
this.posthog = new PostHogService(env);
117120
this.loadPersistedState();
118121
}
119122

@@ -162,6 +165,20 @@ export class Connection {
162165
}
163166
}
164167

168+
private emitAnalytics(event: string, properties: Record<string, unknown>) {
169+
const filtered: Record<string, unknown> = {};
170+
for (const [key, value] of Object.entries(properties)) {
171+
if (value !== undefined) {
172+
filtered[key] = value;
173+
}
174+
}
175+
try {
176+
this.posthog.track(event, filtered);
177+
} catch {
178+
// ignore analytics failures
179+
}
180+
}
181+
165182
private deserializeAirportState(airport: string, stored: unknown): AirportState {
166183
const airportState = (stored || {}) as {
167184
objects?: Record<string, { id: string; state: unknown; controllerId?: string; timestamp: number }>;
@@ -499,7 +516,7 @@ export class Connection {
499516
}
500517
const removed = this.unregisterSocket(socket);
501518
if (removed) {
502-
await this.trackDisconnection();
519+
await this.trackDisconnection(removed, 'banned');
503520
}
504521
socket.close(1008, 'Banned');
505522
clearInterval(interval);
@@ -529,7 +546,7 @@ export class Connection {
529546

530547
const removed = this.unregisterSocket(socket);
531548
if (removed) {
532-
await this.trackDisconnection();
549+
await this.trackDisconnection(removed, 'vatsim_offline');
533550
}
534551
socket.close(1000, 'No longer connected to VATSIM');
535552
clearInterval(interval);
@@ -560,7 +577,7 @@ export class Connection {
560577

561578
const removed = this.unregisterSocket(socket);
562579
if (removed) {
563-
await this.trackDisconnection();
580+
await this.trackDisconnection(removed, 'role_changed');
564581
}
565582
socket.close(1000, 'Role changed on VATSIM');
566583
clearInterval(interval);
@@ -704,7 +721,7 @@ export class Connection {
704721
this.startHeartbeat(server);
705722

706723
// Track connection in background to avoid blocking WS upgrade on slow D1
707-
this.trackConnection(clientType).catch((err) => {
724+
this.trackConnection(clientType, airport).catch((err) => {
708725
console.error('trackConnection failed:', err);
709726
});
710727

@@ -816,7 +833,7 @@ export class Connection {
816833
);
817834
const removed = this.unregisterSocket(server);
818835
if (removed) {
819-
await this.trackDisconnection();
836+
await this.trackDisconnection(removed, 'banned');
820837
}
821838
server.close(1008, 'Banned');
822839
return;
@@ -858,7 +875,14 @@ export class Connection {
858875
};
859876

860877
await this.broadcastToControllers(broadcastPacket, server);
861-
await this.trackMessage(clientType);
878+
this.trackMessage({
879+
clientType,
880+
messageType: 'STOPBAR_CROSSING',
881+
airport,
882+
meta: {
883+
objectId,
884+
},
885+
});
862886
break;
863887
}
864888

@@ -913,7 +937,21 @@ export class Connection {
913937
timestamp,
914938
};
915939
await this.broadcast(broadcastPacket, server);
916-
await this.trackMessage(clientType);
940+
const data = ((packet as Packet).data || {}) as Record<string, unknown>;
941+
const patchValue = data.patch as unknown;
942+
const meta: Record<string, unknown> = {
943+
objectId: typeof data.objectId === 'string' ? data.objectId : undefined,
944+
updateMode: patchValue !== undefined ? 'patch' : 'state',
945+
};
946+
if (patchValue && typeof patchValue === 'object' && !Array.isArray(patchValue)) {
947+
meta.patchKeys = Object.keys(patchValue as Record<string, unknown>).length;
948+
}
949+
this.trackMessage({
950+
clientType,
951+
messageType: 'STATE_UPDATE',
952+
airport: socketInfo.airport,
953+
meta,
954+
});
917955
} catch (updateError) {
918956
throw new Error(
919957
`State update failed: ${updateError instanceof Error ? updateError.message : String(updateError)}`,
@@ -928,7 +966,7 @@ export class Connection {
928966
}
929967
const removed = this.unregisterSocket(server);
930968
if (removed) {
931-
await this.trackDisconnection();
969+
await this.trackDisconnection(removed, 'client_close');
932970
}
933971
server.close(1000, 'Client requested disconnection');
934972
break;
@@ -988,7 +1026,7 @@ export class Connection {
9881026
return;
9891027
}
9901028
try {
991-
await this.trackDisconnection();
1029+
await this.trackDisconnection(removed, 'close_event');
9921030
} catch (e) {
9931031
console.warn('trackDisconnection failed on close (non-fatal):', e);
9941032
}
@@ -1008,7 +1046,7 @@ export class Connection {
10081046
return;
10091047
}
10101048
try {
1011-
await this.trackDisconnection();
1049+
await this.trackDisconnection(removed, 'socket_error');
10121050
} catch (e) {
10131051
console.warn('trackDisconnection failed on error (non-fatal):', e);
10141052
}
@@ -1017,8 +1055,7 @@ export class Connection {
10171055
return new Response(null, { status: 101, webSocket: client });
10181056
}
10191057

1020-
private async trackConnection(_clientType: ClientType) {
1021-
void _clientType;
1058+
private async trackConnection(clientType: ClientType, airport: string) {
10221059
await this.updateActiveConnections(1);
10231060

10241061
// Add this object to active_objects table when first connection is made
@@ -1034,9 +1071,19 @@ export class Connection {
10341071
console.warn('Failed to upsert active_objects on connect (non-fatal):', e instanceof Error ? e.message : e);
10351072
}
10361073
}
1074+
1075+
this.emitAnalytics('ws_connection_opened', {
1076+
airport,
1077+
clientType,
1078+
socket_count: this.sockets.size,
1079+
object_id: this.objectId,
1080+
controllers_online: this.connectionCounts.controllers,
1081+
pilots_online: this.connectionCounts.pilots,
1082+
observers_online: this.connectionCounts.observers,
1083+
});
10371084
}
10381085

1039-
private async trackDisconnection() {
1086+
private async trackDisconnection(info: { controllerId: string; type: ClientType; airport: string }, reason?: string) {
10401087
await this.updateActiveConnections(-1);
10411088

10421089
// If no more connections, remove from active_objects
@@ -1049,6 +1096,17 @@ export class Connection {
10491096
console.warn('Failed to delete active_objects on disconnect (non-fatal):', e instanceof Error ? e.message : e);
10501097
}
10511098
}
1099+
1100+
this.emitAnalytics('ws_connection_closed', {
1101+
airport: info.airport,
1102+
clientType: info.type,
1103+
reason: reason || 'unspecified',
1104+
socket_count: this.sockets.size,
1105+
object_id: this.objectId,
1106+
controllers_online: this.connectionCounts.controllers,
1107+
pilots_online: this.connectionCounts.pilots,
1108+
observers_online: this.connectionCounts.observers,
1109+
});
10521110
}
10531111
private getObjectName(): string {
10541112
// Create a descriptive name with format: airport/controllerCount/pilotCount/observerCount
@@ -1079,9 +1137,20 @@ export class Connection {
10791137
}
10801138
}
10811139

1082-
private async trackMessage(_clientType: ClientType) {
1083-
void _clientType;
1084-
// TODO add posthog tracking of message types
1140+
private trackMessage(details: {
1141+
clientType: ClientType;
1142+
messageType: Packet['type'];
1143+
airport: string;
1144+
meta?: Record<string, unknown>;
1145+
}) {
1146+
const props: Record<string, unknown> = {
1147+
airport: details.airport,
1148+
clientType: details.clientType,
1149+
messageType: details.messageType,
1150+
socket_count: this.sockets.size,
1151+
...details.meta,
1152+
};
1153+
this.emitAnalytics('ws_message', props);
10851154
}
10861155

10871156
private async updateActiveConnections(change: number) {
@@ -1233,12 +1302,15 @@ export class Connection {
12331302
}
12341303

12351304
const patch = packet.data.sharedStatePatch as Record<string, unknown>;
1305+
const patchKeyCount = Object.keys(patch).length;
1306+
let patchSize = 0;
12361307

12371308
// Validate patch structure and size
12381309
try {
12391310
const patchString = JSON.stringify(patch);
1311+
patchSize = patchString.length;
12401312
const MAX_PATCH_SIZE = 10240; // 10KB limit
1241-
if (patchString.length > MAX_PATCH_SIZE) {
1313+
if (patchSize > MAX_PATCH_SIZE) {
12421314
throw new Error(`Patch size exceeds maximum allowed size of ${MAX_PATCH_SIZE} characters`);
12431315
}
12441316
} catch {
@@ -1260,6 +1332,16 @@ export class Connection {
12601332
// Broadcast to all clients (including sender)
12611333
this.broadcastSharedState(airport, patch, controllerId);
12621334

1335+
this.trackMessage({
1336+
clientType: 'controller',
1337+
messageType: 'SHARED_STATE_UPDATE',
1338+
airport,
1339+
meta: {
1340+
patchKeys: patchKeyCount,
1341+
patchSize,
1342+
},
1343+
});
1344+
12631345
return updatedState;
12641346
} catch (error) {
12651347
console.error(`Shared state update error for controller ${controllerId}:`, error);

0 commit comments

Comments
 (0)