Skip to content

Commit

Permalink
reimplement types
Browse files Browse the repository at this point in the history
  • Loading branch information
jthrilly committed Feb 1, 2024
1 parent ef62e4e commit 78ca6af
Show file tree
Hide file tree
Showing 2 changed files with 100 additions and 53 deletions.
2 changes: 1 addition & 1 deletion apps/analytics/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const eventsTable = pgTable(
type: text("type").notNull(),
installationId: text("installationId").notNull(),
timestamp: timestamp("timestamp").notNull(),
isocode: text("isocode"),
countryISOCode: text("countryISOCode").notNull(),
message: text("message"),
name: text("name"),
stack: text("stack"),
Expand Down
151 changes: 99 additions & 52 deletions packages/analytics/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,51 +3,76 @@ import { WebServiceClient } from "@maxmind/geoip2-node";
import { ensureError, getBaseUrl } from "./utils";
import z from "zod";

// Todo: it would be great to work out a way to support arbitrary types here.
export const eventTypes = [
"AppSetup",
"ProtocolInstalled",
"InterviewStarted",
"InterviewCompleted",
"DataExported",
"Error",
] as const;

export type EventType = (typeof eventTypes)[number];
type EventTypeWithoutError = Exclude<EventType, "Error">;

export const EventsSchema = z.object({
type: z.enum(eventTypes),
installationId: z.string(),
timestamp: z.string(),
isocode: z.string().optional(),
error: z
.object({
message: z.string(),
name: z.string(),
stack: z.string().optional(),
})
.optional(),
metadata: z.record(z.unknown()).optional(),
});

export type Event = z.infer<typeof EventsSchema>;

export type AnalyticsEvent = {
type: EventTypeWithoutError;
metadata?: Record<string, unknown>;
};

export type AnalyticsError = {
type: "Error";
error: Error;
metadata?: Record<string, unknown>;
};

export type AnalyticsEventOrError = AnalyticsEvent | AnalyticsError;

export type AnalyticsEventOrErrorWithTimestamp = AnalyticsEventOrError & {
timestamp: string;
};
// Properties that everything has in common.
const SharedEventAndErrorSchema = z
.object({
metadata: z.record(z.unknown()).optional(),
})
.strict();

const EventSchema = z
.object({
type: z.enum(eventTypes),
})
.strict();

const ErrorSchema = z
.object({
type: z.literal("Error"),
error: z
.object({
message: z.string(),
name: z.string(),
stack: z.string().optional(),
})
.strict(),
})
.strict();

// Raw events are the events that are sent to the route handler. They could be
// any of the event types, or an error, based on the type property.
const RawEventSchema = z.discriminatedUnion("type", [
SharedEventAndErrorSchema.merge(EventSchema),
SharedEventAndErrorSchema.merge(ErrorSchema),
]);
export type RawEvent = z.infer<typeof RawEventSchema>;

// This property is added by trackEvent
const TrackablePropertiesSchema = z
.object({
timestamp: z.string(),
})
.strict();

const TrackableEventSchema = z.intersection(
RawEventSchema,
TrackablePropertiesSchema
);
export type TrackableEvent = z.infer<typeof TrackableEventSchema>;

// These properties are added by the route handler
const DispatchablePropertiesSchema = z
.object({
installationId: z.string(),
countryISOCode: z.string(),
})
.strict();

// Events that are ready to be sent to the platform
const DispatchableEventSchema = z.intersection(
TrackableEventSchema,
DispatchablePropertiesSchema
);
export type DispatchableEvent = z.infer<typeof DispatchableEventSchema>;

type RouteHandlerConfiguration = {
platformUrl?: string;
Expand All @@ -62,20 +87,38 @@ export const createRouteHandler = ({
}: RouteHandlerConfiguration) => {
return async (request: NextRequest) => {
try {
const event =
(await request.json()) as AnalyticsEventOrErrorWithTimestamp;
const incomingEvent = (await request.json()) as unknown;

const ip = await fetch("https://api64.ipify.org").then((res) =>
res.text()
);
// Validate the event
const trackableEvent = TrackableEventSchema.safeParse(incomingEvent);

const { country } = await maxMindClient.country(ip);
const countryCode = country?.isoCode ?? "Unknown";
if (!trackableEvent.success) {
console.error("Invalid event:", trackableEvent.error);
return new Response(JSON.stringify({ error: "Invalid event" }), {
status: 400,
headers: {
"Content-Type": "application/json",
},
});
}

// We don't want failures in third party services to prevent us from
// tracking analytics events.
let countryISOCode = "Unknown";
try {
const ip = await fetch("https://api64.ipify.org").then((res) =>
res.text()
);
const { country } = await maxMindClient.country(ip);
countryISOCode = country?.isoCode ?? "Unknown";
} catch (e) {
console.error("Geolocation failed:", e);
}

const dispatchableEvent: Event = {
...event,
const dispatchableEvent: DispatchableEvent = {
...trackableEvent.data,
installationId,
isocode: countryCode,
countryISOCode,
};

// Forward to microservice
Expand All @@ -91,7 +134,7 @@ export const createRouteHandler = ({
if (!response.ok) {
if (response.status === 404) {
console.error(
`Analytics platform not found. Please specify a valid platform URL.`
`Analytics platform could not be reached. Please specify a valid platform URL, or check that the platform is online.`
);
} else if (response.status === 500) {
console.error(
Expand Down Expand Up @@ -140,12 +183,17 @@ export const createRouteHandler = ({

export const makeEventTracker =
(endpoint: string = "/api/analytics") =>
async (event: AnalyticsEventOrError) => {
async (event: RawEvent) => {
// If analytics is disabled don't send analytics events.
if (process.env.DISABLE_ANALYTICS === "true") {
return;
}

const endpointWithHost = getBaseUrl() + endpoint;

const eventWithTimeStamp = {
const eventWithTimeStamp: TrackableEvent = {
...event,
timestamp: new Date(),
timestamp: new Date().toJSON(),
};

try {
Expand Down Expand Up @@ -179,7 +227,6 @@ export const makeEventTracker =
}
} catch (e) {
const error = ensureError(e);

console.error("Internal error with analytics:", error.message);
}
};

0 comments on commit 78ca6af

Please sign in to comment.