improve(queue): how we handle incoming events and session ends
This commit is contained in:
@@ -55,15 +55,6 @@ export async function postEvent(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isScreenView = request.body.name === 'screen_view';
|
|
||||||
// this will ensure that we don't have multiple events creating sessions
|
|
||||||
const LOCK_DURATION = 1000;
|
|
||||||
const locked = await getLock(
|
|
||||||
`request:priority:${currentDeviceId}-${previousDeviceId}:${isScreenView ? 'screen_view' : 'other'}`,
|
|
||||||
'locked',
|
|
||||||
LOCK_DURATION,
|
|
||||||
);
|
|
||||||
|
|
||||||
await eventsQueue.add(
|
await eventsQueue.add(
|
||||||
'event',
|
'event',
|
||||||
{
|
{
|
||||||
@@ -79,7 +70,6 @@ export async function postEvent(
|
|||||||
geo,
|
geo,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
priority: locked,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -88,10 +78,6 @@ export async function postEvent(
|
|||||||
type: 'exponential',
|
type: 'exponential',
|
||||||
delay: 200,
|
delay: 200,
|
||||||
},
|
},
|
||||||
// Prioritize 'screen_view' events by setting no delay
|
|
||||||
// This ensures that session starts are created from 'screen_view' events
|
|
||||||
// rather than other events, maintaining accurate session tracking
|
|
||||||
delay: isScreenView ? undefined : LOCK_DURATION - 100,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -284,15 +284,6 @@ async function track({
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
isTimestampFromThePast: boolean;
|
isTimestampFromThePast: boolean;
|
||||||
}) {
|
}) {
|
||||||
const isScreenView = payload.name === 'screen_view';
|
|
||||||
// this will ensure that we don't have multiple events creating sessions
|
|
||||||
const LOCK_DURATION = 1000;
|
|
||||||
const locked = await getLock(
|
|
||||||
`request:priority:${currentDeviceId}-${previousDeviceId}:${isScreenView ? 'screen_view' : 'other'}`,
|
|
||||||
'locked',
|
|
||||||
LOCK_DURATION,
|
|
||||||
);
|
|
||||||
|
|
||||||
await eventsQueue.add(
|
await eventsQueue.add(
|
||||||
'event',
|
'event',
|
||||||
{
|
{
|
||||||
@@ -308,7 +299,6 @@ async function track({
|
|||||||
geo,
|
geo,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
priority: locked,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -317,10 +307,6 @@ async function track({
|
|||||||
type: 'exponential',
|
type: 'exponential',
|
||||||
delay: 200,
|
delay: 200,
|
||||||
},
|
},
|
||||||
// Prioritize 'screen_view' events by setting no delay
|
|
||||||
// This ensures that session starts are created from 'screen_view' events
|
|
||||||
// rather than other events, maintaining accurate session tracking
|
|
||||||
delay: isScreenView ? undefined : LOCK_DURATION - 100,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const options: Options = {
|
|||||||
'@node-rs/argon2',
|
'@node-rs/argon2',
|
||||||
'bcrypt',
|
'bcrypt',
|
||||||
],
|
],
|
||||||
ignoreWatch: ['../../**/{.git,node_modules}/**'],
|
ignoreWatch: ['../../**/{.git,node_modules,dist}/**'],
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
splitting: false,
|
splitting: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
import { last } from 'ramda';
|
|
||||||
|
|
||||||
import { logger as baseLogger } from '@/utils/logger';
|
import { logger as baseLogger } from '@/utils/logger';
|
||||||
import { getTime } from '@openpanel/common';
|
import { getTime } from '@openpanel/common';
|
||||||
@@ -9,61 +8,56 @@ import {
|
|||||||
checkNotificationRulesForSessionEnd,
|
checkNotificationRulesForSessionEnd,
|
||||||
createEvent,
|
createEvent,
|
||||||
eventBuffer,
|
eventBuffer,
|
||||||
|
formatClickhouseDate,
|
||||||
getEvents,
|
getEvents,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import type { ILogger } from '@openpanel/logger';
|
|
||||||
import type { EventsQueuePayloadCreateSessionEnd } from '@openpanel/queue';
|
import type { EventsQueuePayloadCreateSessionEnd } from '@openpanel/queue';
|
||||||
|
|
||||||
async function getCompleteSession({
|
// Grabs session_start and screen_views + the last occured event
|
||||||
|
async function getNecessarySessionEvents({
|
||||||
projectId,
|
projectId,
|
||||||
sessionId,
|
sessionId,
|
||||||
hoursInterval,
|
createdAt,
|
||||||
}: {
|
}: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
hoursInterval: number;
|
createdAt: Date;
|
||||||
}) {
|
}): Promise<ReturnType<typeof getEvents>> {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT * FROM ${TABLE_NAMES.events}
|
SELECT * FROM ${TABLE_NAMES.events}
|
||||||
WHERE
|
WHERE
|
||||||
session_id = '${sessionId}'
|
session_id = '${sessionId}'
|
||||||
AND project_id = '${projectId}'
|
AND project_id = '${projectId}'
|
||||||
AND created_at > now() - interval ${hoursInterval} HOUR
|
AND created_at >= '${formatClickhouseDate(new Date(new Date(createdAt).getTime() - 1000 * 60 * 5))}'
|
||||||
ORDER BY created_at DESC
|
AND (
|
||||||
|
name IN ('screen_view', 'session_start')
|
||||||
|
OR created_at = (
|
||||||
|
SELECT MAX(created_at)
|
||||||
|
FROM ${TABLE_NAMES.events}
|
||||||
|
WHERE session_id = '${sessionId}'
|
||||||
|
AND project_id = '${projectId}'
|
||||||
|
AND created_at >= '${formatClickhouseDate(new Date(new Date(createdAt).getTime() - 1000 * 60 * 5))}'
|
||||||
|
AND name NOT IN ('screen_view', 'session_start')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY created_at DESC;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return getEvents(sql);
|
const [lastScreenView, eventsInDb] = await Promise.all([
|
||||||
}
|
eventBuffer.getLastScreenView({
|
||||||
|
|
||||||
async function getCompleteSessionWithSessionStart({
|
|
||||||
projectId,
|
projectId,
|
||||||
sessionId,
|
sessionId,
|
||||||
logger,
|
}),
|
||||||
}: {
|
getEvents(sql),
|
||||||
projectId: string;
|
]);
|
||||||
sessionId: string;
|
|
||||||
logger: ILogger;
|
|
||||||
}): Promise<ReturnType<typeof getEvents>> {
|
|
||||||
const intervals = [1, 6, 12, 24, 72];
|
|
||||||
let intervalIndex = 0;
|
|
||||||
for (const hoursInterval of intervals) {
|
|
||||||
const events = await getCompleteSession({
|
|
||||||
projectId,
|
|
||||||
sessionId,
|
|
||||||
hoursInterval,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (events.find((event) => event.name === 'session_start')) {
|
// sort last inserted first
|
||||||
return events;
|
return [lastScreenView, ...eventsInDb]
|
||||||
}
|
.filter((event): event is IServiceEvent => !!event)
|
||||||
|
.sort(
|
||||||
const nextHoursInterval = intervals[++intervalIndex];
|
(a, b) =>
|
||||||
if (nextHoursInterval) {
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
logger.warn(`Checking last ${nextHoursInterval} hours for session_start`);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSessionEnd(
|
export async function createSessionEnd(
|
||||||
@@ -77,56 +71,27 @@ export async function createSessionEnd(
|
|||||||
|
|
||||||
const payload = job.data.payload;
|
const payload = job.data.payload;
|
||||||
|
|
||||||
const [lastScreenView, eventsInDb] = await Promise.all([
|
const events = await getNecessarySessionEvents({
|
||||||
eventBuffer.getLastScreenView({
|
|
||||||
projectId: payload.projectId,
|
projectId: payload.projectId,
|
||||||
sessionId: payload.sessionId,
|
sessionId: payload.sessionId,
|
||||||
}),
|
createdAt: payload.createdAt,
|
||||||
getCompleteSessionWithSessionStart({
|
});
|
||||||
projectId: payload.projectId,
|
|
||||||
sessionId: payload.sessionId,
|
|
||||||
logger,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// sort last inserted first
|
const sessionStart = events.find((event) => event.name === 'session_start');
|
||||||
const events = [lastScreenView, ...eventsInDb]
|
|
||||||
.filter((event): event is IServiceEvent => !!event)
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const sessionDuration = events.reduce((acc, event) => {
|
|
||||||
return acc + event.duration;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
let sessionStart = events.find((event) => event.name === 'session_start');
|
|
||||||
const lastEvent = events[0];
|
|
||||||
const screenViews = events.filter((event) => event.name === 'screen_view');
|
const screenViews = events.filter((event) => event.name === 'screen_view');
|
||||||
|
const lastEvent = events[0];
|
||||||
|
|
||||||
if (!sessionStart) {
|
if (!sessionStart) {
|
||||||
const firstScreenView = last(screenViews);
|
throw new Error('No session_start found');
|
||||||
|
|
||||||
if (!firstScreenView) {
|
|
||||||
throw new Error('Could not found session_start or any screen_view');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn('Creating session_start since it was not found');
|
|
||||||
|
|
||||||
sessionStart = {
|
|
||||||
...firstScreenView,
|
|
||||||
name: 'session_start',
|
|
||||||
createdAt: new Date(getTime(firstScreenView.createdAt) - 100),
|
|
||||||
};
|
|
||||||
|
|
||||||
await createEvent(sessionStart);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!lastEvent) {
|
if (!lastEvent) {
|
||||||
throw new Error('No last event found');
|
throw new Error('No last event found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionDuration =
|
||||||
|
lastEvent.createdAt.getTime() - sessionStart.createdAt.getTime();
|
||||||
|
|
||||||
await checkNotificationRulesForSessionEnd(events);
|
await checkNotificationRulesForSessionEnd(events);
|
||||||
|
|
||||||
logger.info('Creating session_end', {
|
logger.info('Creating session_end', {
|
||||||
@@ -135,7 +100,6 @@ export async function createSessionEnd(
|
|||||||
screenViews,
|
screenViews,
|
||||||
sessionDuration,
|
sessionDuration,
|
||||||
events,
|
events,
|
||||||
lastScreenView: lastScreenView ? lastScreenView : 'none',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return createEvent({
|
return createEvent({
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { getReferrerWithQuery, parseReferrer } from '@/utils/parse-referrer';
|
|
||||||
import type { Job } from 'bullmq';
|
|
||||||
import { omit } from 'ramda';
|
|
||||||
|
|
||||||
import { logger as baseLogger } from '@/utils/logger';
|
import { logger as baseLogger } from '@/utils/logger';
|
||||||
import { createSessionEnd, getSessionEnd } from '@/utils/session-handler';
|
import { getReferrerWithQuery, parseReferrer } from '@/utils/parse-referrer';
|
||||||
|
import {
|
||||||
|
createSessionEndJob,
|
||||||
|
createSessionStart,
|
||||||
|
getSessionEnd,
|
||||||
|
} from '@/utils/session-handler';
|
||||||
import { isSameDomain, parsePath } from '@openpanel/common';
|
import { isSameDomain, parsePath } from '@openpanel/common';
|
||||||
import { parseUserAgent } from '@openpanel/common/server';
|
import { parseUserAgent } from '@openpanel/common/server';
|
||||||
import type { IServiceCreateEventPayload, IServiceEvent } from '@openpanel/db';
|
import type { IServiceCreateEventPayload, IServiceEvent } from '@openpanel/db';
|
||||||
@@ -14,7 +15,11 @@ import {
|
|||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import type { ILogger } from '@openpanel/logger';
|
import type { ILogger } from '@openpanel/logger';
|
||||||
import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue';
|
import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue';
|
||||||
|
import { getLock } from '@openpanel/redis';
|
||||||
|
import { DelayedError, type Job } from 'bullmq';
|
||||||
|
import { omit } from 'ramda';
|
||||||
import * as R from 'ramda';
|
import * as R from 'ramda';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
const GLOBAL_PROPERTIES = ['__path', '__referrer'];
|
const GLOBAL_PROPERTIES = ['__path', '__referrer'];
|
||||||
|
|
||||||
@@ -29,16 +34,18 @@ async function createEventAndNotify(
|
|||||||
jobData: Job<EventsQueuePayloadIncomingEvent>['data']['payload'],
|
jobData: Job<EventsQueuePayloadIncomingEvent>['data']['payload'],
|
||||||
logger: ILogger,
|
logger: ILogger,
|
||||||
) {
|
) {
|
||||||
await checkNotificationRulesForEvent(payload).catch((e) => {
|
|
||||||
logger.error('Error checking notification rules', { error: e });
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info('Creating event', { event: payload, jobData });
|
logger.info('Creating event', { event: payload, jobData });
|
||||||
|
const [event] = await Promise.all([
|
||||||
return createEvent(payload);
|
createEvent(payload),
|
||||||
|
checkNotificationRulesForEvent(payload),
|
||||||
|
]);
|
||||||
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function incomingEvent(job: Job<EventsQueuePayloadIncomingEvent>) {
|
export async function incomingEvent(
|
||||||
|
job: Job<EventsQueuePayloadIncomingEvent>,
|
||||||
|
token?: string,
|
||||||
|
) {
|
||||||
const {
|
const {
|
||||||
geo,
|
geo,
|
||||||
event: body,
|
event: body,
|
||||||
@@ -46,7 +53,6 @@ export async function incomingEvent(job: Job<EventsQueuePayloadIncomingEvent>) {
|
|||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
priority,
|
|
||||||
} = job.data.payload;
|
} = job.data.payload;
|
||||||
const properties = body.properties ?? {};
|
const properties = body.properties ?? {};
|
||||||
const reqId = headers['request-id'] ?? 'unknown';
|
const reqId = headers['request-id'] ?? 'unknown';
|
||||||
@@ -131,32 +137,50 @@ export async function incomingEvent(job: Job<EventsQueuePayloadIncomingEvent>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionEnd = await getSessionEnd({
|
const sessionEnd = await getSessionEnd({
|
||||||
priority,
|
|
||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
profileId,
|
profileId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastScreenView = await eventBuffer.getLastScreenView({
|
const lastScreenView = sessionEnd
|
||||||
|
? await eventBuffer.getLastScreenView({
|
||||||
projectId,
|
projectId,
|
||||||
sessionId: sessionEnd.payload.sessionId,
|
sessionId: sessionEnd.sessionId,
|
||||||
});
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
const payload: IServiceCreateEventPayload = merge(baseEvent, {
|
const payload: IServiceCreateEventPayload = merge(baseEvent, {
|
||||||
deviceId: sessionEnd.payload.deviceId,
|
deviceId: sessionEnd?.deviceId ?? currentDeviceId,
|
||||||
sessionId: sessionEnd.payload.sessionId,
|
sessionId: sessionEnd?.sessionId ?? uuid(),
|
||||||
referrer: sessionEnd.payload?.referrer,
|
referrer: sessionEnd?.referrer ?? baseEvent.referrer,
|
||||||
referrerName: sessionEnd.payload?.referrerName,
|
referrerName: sessionEnd?.referrerName ?? baseEvent.referrerName,
|
||||||
referrerType: sessionEnd.payload?.referrerType,
|
referrerType: sessionEnd?.referrerType ?? baseEvent.referrerType,
|
||||||
// if the path is not set, use the last screen view path
|
// if the path is not set, use the last screen view path
|
||||||
path: baseEvent.path || lastScreenView?.path || '',
|
path: baseEvent.path || lastScreenView?.path || '',
|
||||||
origin: baseEvent.origin || lastScreenView?.origin || '',
|
origin: baseEvent.origin || lastScreenView?.origin || '',
|
||||||
} as Partial<IServiceCreateEventPayload>) as IServiceCreateEventPayload;
|
} as Partial<IServiceCreateEventPayload>) as IServiceCreateEventPayload;
|
||||||
|
|
||||||
if (sessionEnd.notFound) {
|
if (!sessionEnd) {
|
||||||
await createSessionEnd({ payload });
|
// Too avoid several created sessions we just throw if a lock exists
|
||||||
|
// This will than retry the job
|
||||||
|
const lock = await getLock(
|
||||||
|
`create-session-end:${currentDeviceId}`,
|
||||||
|
'locked',
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lock) {
|
||||||
|
logger.warn('Move incoming event to delayed');
|
||||||
|
await job.moveToDelayed(Date.now() + 50, token);
|
||||||
|
throw new DelayedError();
|
||||||
|
}
|
||||||
|
await createSessionStart({ payload });
|
||||||
}
|
}
|
||||||
|
|
||||||
return createEventAndNotify(payload, job.data.payload, logger);
|
const event = await createEventAndNotify(payload, job.data.payload, logger);
|
||||||
|
|
||||||
|
await createSessionEndJob({ payload });
|
||||||
|
|
||||||
|
return event;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import type {
|
|||||||
|
|
||||||
import { incomingEvent } from './events.incoming-event';
|
import { incomingEvent } from './events.incoming-event';
|
||||||
|
|
||||||
export async function eventsJob(job: Job<EventsQueuePayload>) {
|
export async function eventsJob(job: Job<EventsQueuePayload>, token?: string) {
|
||||||
return await incomingEvent(job as Job<EventsQueuePayloadIncomingEvent>);
|
return await incomingEvent(
|
||||||
|
job as Job<EventsQueuePayloadIncomingEvent>,
|
||||||
|
token,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,33 @@ import { type IServiceCreateEventPayload, createEvent } from '@openpanel/db';
|
|||||||
import {
|
import {
|
||||||
type EventsQueuePayloadCreateSessionEnd,
|
type EventsQueuePayloadCreateSessionEnd,
|
||||||
sessionsQueue,
|
sessionsQueue,
|
||||||
sessionsQueueEvents,
|
|
||||||
} from '@openpanel/queue';
|
} from '@openpanel/queue';
|
||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { logger } from './logger';
|
||||||
|
|
||||||
export const SESSION_TIMEOUT = 1000 * 60 * 30;
|
export const SESSION_TIMEOUT = 1000 * 60 * 30;
|
||||||
|
|
||||||
const getSessionEndJobId = (projectId: string, deviceId: string) =>
|
const getSessionEndJobId = (projectId: string, deviceId: string) =>
|
||||||
`sessionEnd:${projectId}:${deviceId}`;
|
`sessionEnd:${projectId}:${deviceId}`;
|
||||||
|
|
||||||
export async function createSessionEnd({
|
export async function createSessionStart({
|
||||||
payload,
|
payload,
|
||||||
}: {
|
}: {
|
||||||
payload: IServiceCreateEventPayload;
|
payload: IServiceCreateEventPayload;
|
||||||
}) {
|
}) {
|
||||||
await sessionsQueue.add(
|
return createEvent({
|
||||||
|
...payload,
|
||||||
|
name: 'session_start',
|
||||||
|
createdAt: new Date(getTime(payload.createdAt) - 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSessionEndJob({
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
payload: IServiceCreateEventPayload;
|
||||||
|
}) {
|
||||||
|
return sessionsQueue.add(
|
||||||
'session',
|
'session',
|
||||||
{
|
{
|
||||||
type: 'createSessionEnd',
|
type: 'createSessionEnd',
|
||||||
@@ -34,12 +45,6 @@ export async function createSessionEnd({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await createEvent({
|
|
||||||
...payload,
|
|
||||||
name: 'session_start',
|
|
||||||
createdAt: new Date(getTime(payload.createdAt) - 100),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSessionEnd({
|
export async function getSessionEnd({
|
||||||
@@ -47,42 +52,33 @@ export async function getSessionEnd({
|
|||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
profileId,
|
profileId,
|
||||||
priority,
|
|
||||||
}: {
|
}: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
currentDeviceId: string;
|
currentDeviceId: string;
|
||||||
previousDeviceId: string;
|
previousDeviceId: string;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
priority: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const sessionEnd = await getSessionEndJob({
|
const sessionEnd = await getSessionEndJob({
|
||||||
priority,
|
|
||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionEndPayload =
|
|
||||||
sessionEnd?.job.data.payload ||
|
|
||||||
({
|
|
||||||
sessionId: uuid(),
|
|
||||||
deviceId: currentDeviceId,
|
|
||||||
profileId,
|
|
||||||
projectId,
|
|
||||||
} satisfies EventsQueuePayloadCreateSessionEnd['payload']);
|
|
||||||
|
|
||||||
if (sessionEnd) {
|
if (sessionEnd) {
|
||||||
// If for some reason we have a session end job that is not a createSessionEnd job
|
// Hack: if session end job just got created, we want to give it a chance to complete
|
||||||
if (sessionEnd.job.data.type !== 'createSessionEnd') {
|
// So the order is correct
|
||||||
throw new Error('Invalid session end job');
|
if (sessionEnd.job.timestamp > Date.now() - 50) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the profile_id is set and it's different from the device_id, we need to update the profile_id
|
const existingSessionIsAnonymous =
|
||||||
if (
|
|
||||||
sessionEnd.job.data.payload.profileId !== profileId &&
|
|
||||||
sessionEnd.job.data.payload.profileId ===
|
sessionEnd.job.data.payload.profileId ===
|
||||||
sessionEnd.job.data.payload.deviceId
|
sessionEnd.job.data.payload.deviceId;
|
||||||
) {
|
|
||||||
|
const eventIsIdentified =
|
||||||
|
sessionEnd.job.data.payload.profileId !== profileId;
|
||||||
|
|
||||||
|
if (existingSessionIsAnonymous && eventIsIdentified) {
|
||||||
await sessionEnd.job.updateData({
|
await sessionEnd.job.updateData({
|
||||||
...sessionEnd.job.data,
|
...sessionEnd.job.data,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -93,25 +89,22 @@ export async function getSessionEnd({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await sessionEnd.job.changeDelay(SESSION_TIMEOUT);
|
await sessionEnd.job.changeDelay(SESSION_TIMEOUT);
|
||||||
|
return sessionEnd.job.data.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return null;
|
||||||
payload: sessionEndPayload,
|
|
||||||
notFound: !sessionEnd,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSessionEndJob(args: {
|
export async function getSessionEndJob(args: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
currentDeviceId: string;
|
currentDeviceId: string;
|
||||||
previousDeviceId: string;
|
previousDeviceId: string;
|
||||||
priority: boolean;
|
|
||||||
retryCount?: number;
|
retryCount?: number;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
job: Job<EventsQueuePayloadCreateSessionEnd>;
|
job: Job<EventsQueuePayloadCreateSessionEnd>;
|
||||||
} | null> {
|
} | null> {
|
||||||
const { priority, retryCount = 0 } = args;
|
const { retryCount = 0 } = args;
|
||||||
|
|
||||||
if (retryCount >= 6) {
|
if (retryCount >= 6) {
|
||||||
throw new Error('Failed to get session end');
|
throw new Error('Failed to get session end');
|
||||||
@@ -125,46 +118,32 @@ export async function getSessionEndJob(args: {
|
|||||||
job: Job<EventsQueuePayloadCreateSessionEnd>;
|
job: Job<EventsQueuePayloadCreateSessionEnd>;
|
||||||
} | null> {
|
} | null> {
|
||||||
const state = await job.getState();
|
const state = await job.getState();
|
||||||
if (state === 'delayed') {
|
if (state !== 'delayed') {
|
||||||
|
logger.info(`[session-handler] Session end job is in "${state}" state`, {
|
||||||
|
state,
|
||||||
|
retryCount,
|
||||||
|
jobTimestamp: new Date(job.timestamp).toISOString(),
|
||||||
|
jobDelta: Date.now() - job.timestamp,
|
||||||
|
jobId: job.id,
|
||||||
|
reqId: job.data.payload.properties?.__reqId ?? 'unknown',
|
||||||
|
payload: job.data.payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'delayed' || state === 'waiting') {
|
||||||
return { deviceId, job };
|
return { deviceId, job };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === 'failed') {
|
if (state === 'active') {
|
||||||
await job.retry();
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
await job.waitUntilFinished(sessionsQueueEvents, 1000 * 10);
|
|
||||||
return getSessionEndJob({
|
return getSessionEndJob({
|
||||||
...args,
|
...args,
|
||||||
priority,
|
retryCount: retryCount + 1,
|
||||||
retryCount,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === 'completed') {
|
if (state === 'completed') {
|
||||||
await job.remove();
|
await job.remove();
|
||||||
return getSessionEndJob({
|
|
||||||
...args,
|
|
||||||
priority,
|
|
||||||
retryCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === 'active' || state === 'waiting') {
|
|
||||||
await job.waitUntilFinished(sessionsQueueEvents, 1000 * 10);
|
|
||||||
return getSessionEndJob({
|
|
||||||
...args,
|
|
||||||
priority,
|
|
||||||
retryCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shady state here, just remove it and retry
|
|
||||||
if (state === 'unknown') {
|
|
||||||
await job.remove();
|
|
||||||
return getSessionEndJob({
|
|
||||||
...args,
|
|
||||||
priority,
|
|
||||||
retryCount,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -175,8 +154,7 @@ export async function getSessionEndJob(args: {
|
|||||||
getSessionEndJobId(args.projectId, args.currentDeviceId),
|
getSessionEndJobId(args.projectId, args.currentDeviceId),
|
||||||
);
|
);
|
||||||
if (currentJob) {
|
if (currentJob) {
|
||||||
const res = await handleJobStates(currentJob, args.currentDeviceId);
|
return await handleJobStates(currentJob, args.currentDeviceId);
|
||||||
if (res) return res;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check previous device job
|
// Check previous device job
|
||||||
@@ -184,15 +162,7 @@ export async function getSessionEndJob(args: {
|
|||||||
getSessionEndJobId(args.projectId, args.previousDeviceId),
|
getSessionEndJobId(args.projectId, args.previousDeviceId),
|
||||||
);
|
);
|
||||||
if (previousJob) {
|
if (previousJob) {
|
||||||
const res = await handleJobStates(previousJob, args.previousDeviceId);
|
return await handleJobStates(previousJob, args.previousDeviceId);
|
||||||
if (res) return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no job found and not priority, retry
|
|
||||||
if (!priority) {
|
|
||||||
const backoffDelay = 50 * 2 ** retryCount;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
|
|
||||||
return getSessionEndJob({ ...args, priority, retryCount: retryCount + 1 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const options: Options = {
|
|||||||
entry: ['src/index.ts'],
|
entry: ['src/index.ts'],
|
||||||
noExternal: [/^@openpanel\/.*$/u, /^@\/.*$/u],
|
noExternal: [/^@openpanel\/.*$/u, /^@\/.*$/u],
|
||||||
external: ['@hyperdx/node-opentelemetry', 'winston'],
|
external: ['@hyperdx/node-opentelemetry', 'winston'],
|
||||||
ignoreWatch: ['../../**/{.git,node_modules}/**'],
|
ignoreWatch: ['../../**/{.git,node_modules,dist}/**'],
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
splitting: false,
|
splitting: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,8 +67,6 @@ export class BaseBuffer {
|
|||||||
lockId,
|
lockId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
this.logger.warn('Failed to acquire lock. Skipping flush.', { lockId });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,14 +58,17 @@ export class SessionBuffer extends BaseBuffer {
|
|||||||
if (event.origin) {
|
if (event.origin) {
|
||||||
newSession.exit_origin = event.origin;
|
newSession.exit_origin = event.origin;
|
||||||
}
|
}
|
||||||
newSession.duration =
|
const duration =
|
||||||
new Date(newSession.ended_at).getTime() -
|
new Date(newSession.ended_at).getTime() -
|
||||||
new Date(newSession.created_at).getTime();
|
new Date(newSession.created_at).getTime();
|
||||||
if (newSession.duration < 0) {
|
if (duration > 0) {
|
||||||
|
newSession.duration = duration;
|
||||||
|
} else {
|
||||||
this.logger.warn('Session duration is negative', {
|
this.logger.warn('Session duration is negative', {
|
||||||
|
duration,
|
||||||
|
event,
|
||||||
session: newSession,
|
session: newSession,
|
||||||
});
|
});
|
||||||
newSession.duration = 0;
|
|
||||||
}
|
}
|
||||||
newSession.properties = toDots({
|
newSession.properties = toDots({
|
||||||
...(event.properties || {}),
|
...(event.properties || {}),
|
||||||
@@ -73,7 +76,7 @@ export class SessionBuffer extends BaseBuffer {
|
|||||||
});
|
});
|
||||||
// newSession.revenue += event.properties?.__revenue ?? 0;
|
// newSession.revenue += event.properties?.__revenue ?? 0;
|
||||||
|
|
||||||
if (event.name === 'screen_view') {
|
if (event.name === 'screen_view' && event.path) {
|
||||||
newSession.screen_views.push(event.path);
|
newSession.screen_views.push(event.path);
|
||||||
newSession.screen_view_count += 1;
|
newSession.screen_view_count += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -161,8 +164,6 @@ export class SessionBuffer extends BaseBuffer {
|
|||||||
const sessions = await this.getSession(event);
|
const sessions = await this.getSession(event);
|
||||||
const [newSession] = sessions;
|
const [newSession] = sessions;
|
||||||
|
|
||||||
console.log(`Adding sessions ${sessions.length}`);
|
|
||||||
|
|
||||||
const multi = this.redis.multi();
|
const multi = this.redis.multi();
|
||||||
multi.set(
|
multi.set(
|
||||||
`session:${newSession.id}`,
|
`session:${newSession.id}`,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { path, assocPath, last, mergeDeepRight, pick, uniq } from 'ramda';
|
import { path, assocPath, last, mergeDeepRight } from 'ramda';
|
||||||
import { escape } from 'sqlstring';
|
import { escape } from 'sqlstring';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
import { toDots } from '@openpanel/common';
|
import { DateTime, toDots } from '@openpanel/common';
|
||||||
import { cacheable, getCache } from '@openpanel/redis';
|
import { cacheable, getCache } from '@openpanel/redis';
|
||||||
import type { IChartEventFilter } from '@openpanel/validation';
|
import type { IChartEventFilter } from '@openpanel/validation';
|
||||||
|
|
||||||
@@ -19,13 +19,8 @@ import type { EventMeta, Prisma } from '../prisma-client';
|
|||||||
import { db } from '../prisma-client';
|
import { db } from '../prisma-client';
|
||||||
import { createSqlBuilder } from '../sql-builder';
|
import { createSqlBuilder } from '../sql-builder';
|
||||||
import { getEventFiltersWhereClause } from './chart.service';
|
import { getEventFiltersWhereClause } from './chart.service';
|
||||||
import type { IClickhouseProfile, IServiceProfile } from './profile.service';
|
import type { IServiceProfile } from './profile.service';
|
||||||
import {
|
import { getProfileById, getProfiles, upsertProfile } from './profile.service';
|
||||||
getProfileById,
|
|
||||||
getProfiles,
|
|
||||||
transformProfile,
|
|
||||||
upsertProfile,
|
|
||||||
} from './profile.service';
|
|
||||||
|
|
||||||
export type IImportedEvent = Omit<
|
export type IImportedEvent = Omit<
|
||||||
IClickhouseEvent,
|
IClickhouseEvent,
|
||||||
@@ -293,6 +288,42 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
|
|||||||
payload.profileId = payload.deviceId;
|
payload.profileId = payload.deviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const event: IClickhouseEvent = {
|
||||||
|
id: uuid(),
|
||||||
|
name: payload.name,
|
||||||
|
device_id: payload.deviceId,
|
||||||
|
profile_id: payload.profileId ? String(payload.profileId) : '',
|
||||||
|
project_id: payload.projectId,
|
||||||
|
session_id: payload.sessionId,
|
||||||
|
properties: toDots(payload.properties),
|
||||||
|
path: payload.path ?? '',
|
||||||
|
origin: payload.origin ?? '',
|
||||||
|
created_at: DateTime.fromJSDate(payload.createdAt)
|
||||||
|
.setZone('UTC')
|
||||||
|
.toFormat('yyyy-MM-dd HH:mm:ss.SSS'),
|
||||||
|
country: payload.country ?? '',
|
||||||
|
city: payload.city ?? '',
|
||||||
|
region: payload.region ?? '',
|
||||||
|
longitude: payload.longitude ?? null,
|
||||||
|
latitude: payload.latitude ?? null,
|
||||||
|
os: payload.os ?? '',
|
||||||
|
os_version: payload.osVersion ?? '',
|
||||||
|
browser: payload.browser ?? '',
|
||||||
|
browser_version: payload.browserVersion ?? '',
|
||||||
|
device: payload.device ?? '',
|
||||||
|
brand: payload.brand ?? '',
|
||||||
|
model: payload.model ?? '',
|
||||||
|
duration: payload.duration,
|
||||||
|
referrer: payload.referrer ?? '',
|
||||||
|
referrer_name: payload.referrerName ?? '',
|
||||||
|
referrer_type: payload.referrerType ?? '',
|
||||||
|
imported_at: null,
|
||||||
|
sdk_name: payload.sdkName ?? '',
|
||||||
|
sdk_version: payload.sdkVersion ?? '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all([sessionBuffer.add(event), eventBuffer.add(event)]);
|
||||||
|
|
||||||
if (payload.profileId) {
|
if (payload.profileId) {
|
||||||
const profile = {
|
const profile = {
|
||||||
id: String(payload.profileId),
|
id: String(payload.profileId),
|
||||||
@@ -326,40 +357,6 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const event: IClickhouseEvent = {
|
|
||||||
id: uuid(),
|
|
||||||
name: payload.name,
|
|
||||||
device_id: payload.deviceId,
|
|
||||||
profile_id: payload.profileId ? String(payload.profileId) : '',
|
|
||||||
project_id: payload.projectId,
|
|
||||||
session_id: payload.sessionId,
|
|
||||||
properties: toDots(payload.properties),
|
|
||||||
path: payload.path ?? '',
|
|
||||||
origin: payload.origin ?? '',
|
|
||||||
created_at: formatClickhouseDate(payload.createdAt),
|
|
||||||
country: payload.country ?? '',
|
|
||||||
city: payload.city ?? '',
|
|
||||||
region: payload.region ?? '',
|
|
||||||
longitude: payload.longitude ?? null,
|
|
||||||
latitude: payload.latitude ?? null,
|
|
||||||
os: payload.os ?? '',
|
|
||||||
os_version: payload.osVersion ?? '',
|
|
||||||
browser: payload.browser ?? '',
|
|
||||||
browser_version: payload.browserVersion ?? '',
|
|
||||||
device: payload.device ?? '',
|
|
||||||
brand: payload.brand ?? '',
|
|
||||||
model: payload.model ?? '',
|
|
||||||
duration: payload.duration,
|
|
||||||
referrer: payload.referrer ?? '',
|
|
||||||
referrer_name: payload.referrerName ?? '',
|
|
||||||
referrer_type: payload.referrerType ?? '',
|
|
||||||
imported_at: null,
|
|
||||||
sdk_name: payload.sdkName ?? '',
|
|
||||||
sdk_version: payload.sdkVersion ?? '',
|
|
||||||
};
|
|
||||||
|
|
||||||
await Promise.all([sessionBuffer.add(event), eventBuffer.add(event)]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
document: event,
|
document: event,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,14 +22,18 @@ export interface EventsQueuePayloadIncomingEvent {
|
|||||||
headers: Record<string, string | undefined>;
|
headers: Record<string, string | undefined>;
|
||||||
currentDeviceId: string;
|
currentDeviceId: string;
|
||||||
previousDeviceId: string;
|
previousDeviceId: string;
|
||||||
priority: boolean;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export interface EventsQueuePayloadCreateEvent {
|
export interface EventsQueuePayloadCreateEvent {
|
||||||
type: 'createEvent';
|
type: 'createEvent';
|
||||||
payload: Omit<IServiceEvent, 'id'>;
|
payload: Omit<IServiceEvent, 'id'>;
|
||||||
}
|
}
|
||||||
type SessionEndRequired = 'sessionId' | 'deviceId' | 'profileId' | 'projectId';
|
type SessionEndRequired =
|
||||||
|
| 'sessionId'
|
||||||
|
| 'deviceId'
|
||||||
|
| 'profileId'
|
||||||
|
| 'projectId'
|
||||||
|
| 'createdAt';
|
||||||
export interface EventsQueuePayloadCreateSessionEnd {
|
export interface EventsQueuePayloadCreateSessionEnd {
|
||||||
type: 'createSessionEnd';
|
type: 'createSessionEnd';
|
||||||
payload: Partial<Omit<IServiceEvent, SessionEndRequired>> &
|
payload: Partial<Omit<IServiceEvent, SessionEndRequired>> &
|
||||||
|
|||||||
46
pnpm-lock.yaml
generated
46
pnpm-lock.yaml
generated
@@ -4,6 +4,12 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
catalogs:
|
||||||
|
default:
|
||||||
|
zod:
|
||||||
|
specifier: ^3.24.2
|
||||||
|
version: 3.24.2
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -1066,6 +1072,40 @@ importers:
|
|||||||
specifier: ^5.2.2
|
specifier: ^5.2.2
|
||||||
version: 5.6.3
|
version: 5.6.3
|
||||||
|
|
||||||
|
packages/fire:
|
||||||
|
dependencies:
|
||||||
|
'@faker-js/faker':
|
||||||
|
specifier: ^9.0.1
|
||||||
|
version: 9.0.1
|
||||||
|
'@openpanel/common':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../common
|
||||||
|
'@openpanel/db':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../db
|
||||||
|
csv-parse:
|
||||||
|
specifier: ^5.6.0
|
||||||
|
version: 5.6.0
|
||||||
|
date-fns:
|
||||||
|
specifier: ^3.3.1
|
||||||
|
version: 3.3.1
|
||||||
|
devDependencies:
|
||||||
|
'@openpanel/tsconfig':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../tooling/typescript
|
||||||
|
'@openpanel/validation':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../validation
|
||||||
|
'@types/node':
|
||||||
|
specifier: 20.14.8
|
||||||
|
version: 20.14.8
|
||||||
|
tsup:
|
||||||
|
specifier: ^7.2.0
|
||||||
|
version: 7.3.0(postcss@8.5.3)(typescript@5.6.3)
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.2.2
|
||||||
|
version: 5.6.3
|
||||||
|
|
||||||
packages/integrations:
|
packages/integrations:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@slack/bolt':
|
'@slack/bolt':
|
||||||
@@ -3155,6 +3195,7 @@ packages:
|
|||||||
'@faker-js/faker@9.0.1':
|
'@faker-js/faker@9.0.1':
|
||||||
resolution: {integrity: sha512-4mDeYIgM3By7X6t5E6eYwLAa+2h4DeZDF7thhzIg6XB76jeEvMwadYAMCFJL/R4AnEBcAUO9+gL0vhy3s+qvZA==}
|
resolution: {integrity: sha512-4mDeYIgM3By7X6t5E6eYwLAa+2h4DeZDF7thhzIg6XB76jeEvMwadYAMCFJL/R4AnEBcAUO9+gL0vhy3s+qvZA==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=9.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=9.0.0'}
|
||||||
|
deprecated: Please update to a newer version
|
||||||
|
|
||||||
'@fastify/accept-negotiator@2.0.1':
|
'@fastify/accept-negotiator@2.0.1':
|
||||||
resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==}
|
resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==}
|
||||||
@@ -7485,6 +7526,9 @@ packages:
|
|||||||
csstype@3.1.3:
|
csstype@3.1.3:
|
||||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||||
|
|
||||||
|
csv-parse@5.6.0:
|
||||||
|
resolution: {integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==}
|
||||||
|
|
||||||
d3-array@2.12.1:
|
d3-array@2.12.1:
|
||||||
resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==}
|
resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==}
|
||||||
|
|
||||||
@@ -19905,6 +19949,8 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.1.3: {}
|
csstype@3.1.3: {}
|
||||||
|
|
||||||
|
csv-parse@5.6.0: {}
|
||||||
|
|
||||||
d3-array@2.12.1:
|
d3-array@2.12.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
internmap: 1.0.1
|
internmap: 1.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user