Skip to content

Commit

Permalink
feat: allow fetching workpsace usage CSV by API if feature flag (#3829)
Browse files Browse the repository at this point in the history
* allow downloading workspace usage CSV via API

* rename as unsafe

* add feature flag

* add public api endpoint

* fix

---------

Co-authored-by: Henry Fontanier <[email protected]>
  • Loading branch information
fontanierh and Henry Fontanier authored Feb 20, 2024
1 parent 2c80c72 commit 8e22320
Show file tree
Hide file tree
Showing 4 changed files with 195 additions and 96 deletions.
96 changes: 96 additions & 0 deletions front/lib/workspace_usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { format } from "date-fns";
import { QueryTypes } from "sequelize";

import { front_sequelize } from "@app/lib/databases";

export interface WorkpsaceUsageQueryResult {
createdAt: string;
conversationModelId: string;
messageId: string;
userMessageId: string;
agentMessageId: string;
userId: string;
userFirstName: string;
userLastName: string;
assistantId: string;
assistantName: string;
actionType: string;
source: string;
}

export async function unsafeGetUsageData(
startDate: Date,
endDate: Date,
wId: string
): Promise<string> {
const results = await front_sequelize.query<WorkpsaceUsageQueryResult>(
`
SELECT
TO_CHAR(m."createdAt"::timestamp, 'YYYY-MM-DD HH24:MI:SS') AS "createdAt",
c."id" AS "conversationInternalId",
m."sId" AS "messageId",
p."sId" AS "parentMessageId",
CASE
WHEN um."id" IS NOT NULL THEN 'user'
WHEN am."id" IS NOT NULL THEN 'assistant'
WHEN cf."id" IS NOT NULL THEN 'content_fragment'
END AS "messageType",
um."userContextFullName" AS "userFullName",
um."userContextEmail" AS "userEmail",
COALESCE(ac."sId", am."agentConfigurationId") AS "assistantId",
COALESCE(ac."name", am."agentConfigurationId") AS "assistantName",
CASE
WHEN ac."retrievalConfigurationId" IS NOT NULL THEN 'retrieval'
WHEN ac."dustAppRunConfigurationId" IS NOT NULL THEN 'dustAppRun'
ELSE NULL
END AS "actionType",
CASE
WHEN um."id" IS NOT NULL THEN
CASE
WHEN um."userId" IS NOT NULL THEN 'web'
ELSE 'slack'
END
END AS "source"
FROM
"messages" m
JOIN
"conversations" c ON m."conversationId" = c."id"
JOIN
"workspaces" w ON c."workspaceId" = w."id"
LEFT JOIN
"user_messages" um ON m."userMessageId" = um."id"
LEFT JOIN
"users" u ON um."userId" = u."id"
LEFT JOIN
"agent_messages" am ON m."agentMessageId" = am."id"
LEFT JOIN
"content_fragments" cf ON m."contentFragmentId" = cf."id"
LEFT JOIN
"agent_configurations" ac ON am."agentConfigurationId" = ac."sId" AND am."agentConfigurationVersion" = ac."version"
LEFT JOIN
"messages" p ON m."parentId" = p."id"
WHERE
w."sId" = :wId AND
m."createdAt" >= :startDate AND m."createdAt" <= :endDate
ORDER BY
m."createdAt" DESC
`,
{
replacements: {
wId,
startDate: format(startDate, "yyyy-MM-dd"), // Use first day of start month
endDate: format(endDate, "yyyy-MM-dd"), // Use last day of end month
},
type: QueryTypes.SELECT,
}
);
if (!results.length) {
return "No data available for the selected period.";
}
const csvHeader = Object.keys(results[0]).join(",") + "\n";
const csvContent = results
.map((row) => Object.values(row).join(","))
.join("\n");

return csvHeader + csvContent;
}
95 changes: 95 additions & 0 deletions front/pages/api/v1/w/[wId]/usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { isLeft } from "fp-ts/lib/Either";
import * as t from "io-ts";
import * as reporter from "io-ts-reporters";
import type { NextApiRequest, NextApiResponse } from "next";

import { Authenticator, getAPIKey } from "@app/lib/auth";
import { unsafeGetUsageData } from "@app/lib/workspace_usage";
import { apiError, withLogging } from "@app/logger/withlogging";

const DateString = t.refinement(
t.string,
(s): s is string => /^\d{4}-\d{2}-\d{2}$/.test(s),
"YYYY-MM-DD"
);

const GetWorkspaceUsageSchema = t.intersection([
t.type({
start_date: DateString,
}),
t.partial({
end_date: t.union([DateString, t.undefined, t.null]),
}),
]);

async function handler(
req: NextApiRequest,
res: NextApiResponse
): Promise<void> {
const keyRes = await getAPIKey(req);
if (keyRes.isErr()) {
return apiError(req, res, keyRes.error);
}
const { auth } = await Authenticator.fromKey(
keyRes.value,
req.query.wId as string
);

const owner = auth.workspace();
if (!owner || !auth.isBuilder()) {
return apiError(req, res, {
status_code: 404,
api_error: {
type: "workspace_not_found",
message: "The workspace was not found.",
},
});
}

if (!owner.flags.includes("usage_data_api")) {
return apiError(req, res, {
status_code: 403,
api_error: {
type: "workspace_auth_error",
message: "The workspace does not have access to the usage data API.",
},
});
}

switch (req.method) {
case "GET":
const queryValidation = GetWorkspaceUsageSchema.decode(req.query);
if (isLeft(queryValidation)) {
const pathError = reporter.formatValidationErrors(queryValidation.left);
return apiError(req, res, {
api_error: {
type: "invalid_request_error",
message: `Invalid request query: ${pathError}`,
},
status_code: 400,
});
}

const query = queryValidation.right;

const csvData = await unsafeGetUsageData(
new Date(query.start_date),
query.end_date ? new Date(query.end_date) : new Date(),
owner.sId
);
res.setHeader("Content-Type", "text/csv");
res.status(200).send(csvData);
return;

default:
return apiError(req, res, {
status_code: 405,
api_error: {
type: "method_not_supported_error",
message: "The method passed is not supported, GET is expected.",
},
});
}
}

export default withLogging(handler);
99 changes: 3 additions & 96 deletions front/pages/api/w/[wId]/workspace-usage.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { assertNever } from "@dust-tt/types";
import { endOfMonth, format } from "date-fns";
import { endOfMonth } from "date-fns";
import { isLeft } from "fp-ts/lib/Either";
import * as t from "io-ts";
import * as reporter from "io-ts-reporters";
import type { NextApiRequest, NextApiResponse } from "next";
import { QueryTypes } from "sequelize";

import { Authenticator, getSession } from "@app/lib/auth";
import { front_sequelize } from "@app/lib/databases";
import { unsafeGetUsageData } from "@app/lib/workspace_usage";
import { apiError, withLogging } from "@app/logger/withlogging";

const MonthSchema = t.refinement(
Expand All @@ -34,21 +33,6 @@ const GetUsageQueryParamsSchema = t.union([
}),
]);

interface QueryResult {
createdAt: string;
conversationModelId: string;
messageId: string;
userMessageId: string;
agentMessageId: string;
userId: string;
userFirstName: string;
userLastName: string;
assistantId: string;
assistantName: string;
actionType: string;
source: string;
}

async function handler(
req: NextApiRequest,
res: NextApiResponse
Expand Down Expand Up @@ -120,7 +104,7 @@ async function handler(
}
})();

const csvData = await getUsageData(startDate, endDate, owner.sId);
const csvData = await unsafeGetUsageData(startDate, endDate, owner.sId);
res.setHeader("Content-Type", "text/csv");
res.setHeader("Content-Disposition", `attachment; filename="usage.csv"`);
res.status(200).send(csvData);
Expand All @@ -138,80 +122,3 @@ async function handler(
}

export default withLogging(handler);

async function getUsageData(
startDate: Date,
endDate: Date,
wId: string
): Promise<string> {
const results = await front_sequelize.query<QueryResult>(
`
SELECT
TO_CHAR(m."createdAt"::timestamp, 'YYYY-MM-DD HH24:MI:SS') AS "createdAt",
c."id" AS "conversationInternalId",
m."sId" AS "messageId",
p."sId" AS "parentMessageId",
CASE
WHEN um."id" IS NOT NULL THEN 'user'
WHEN am."id" IS NOT NULL THEN 'assistant'
WHEN cf."id" IS NOT NULL THEN 'content_fragment'
END AS "messageType",
um."userContextFullName" AS "userFullName",
um."userContextEmail" AS "userEmail",
COALESCE(ac."sId", am."agentConfigurationId") AS "assistantId",
COALESCE(ac."name", am."agentConfigurationId") AS "assistantName",
CASE
WHEN ac."retrievalConfigurationId" IS NOT NULL THEN 'retrieval'
WHEN ac."dustAppRunConfigurationId" IS NOT NULL THEN 'dustAppRun'
ELSE NULL
END AS "actionType",
CASE
WHEN um."id" IS NOT NULL THEN
CASE
WHEN um."userId" IS NOT NULL THEN 'web'
ELSE 'slack'
END
END AS "source"
FROM
"messages" m
JOIN
"conversations" c ON m."conversationId" = c."id"
JOIN
"workspaces" w ON c."workspaceId" = w."id"
LEFT JOIN
"user_messages" um ON m."userMessageId" = um."id"
LEFT JOIN
"users" u ON um."userId" = u."id"
LEFT JOIN
"agent_messages" am ON m."agentMessageId" = am."id"
LEFT JOIN
"content_fragments" cf ON m."contentFragmentId" = cf."id"
LEFT JOIN
"agent_configurations" ac ON am."agentConfigurationId" = ac."sId" AND am."agentConfigurationVersion" = ac."version"
LEFT JOIN
"messages" p ON m."parentId" = p."id"
WHERE
w."sId" = :wId AND
m."createdAt" >= :startDate AND m."createdAt" <= :endDate
ORDER BY
m."createdAt" DESC
`,
{
replacements: {
wId,
startDate: format(startDate, "yyyy-MM-dd"), // Use first day of start month
endDate: format(endDate, "yyyy-MM-dd"), // Use last day of end month
},
type: QueryTypes.SELECT,
}
);
if (!results.length) {
return "No data available for the selected period.";
}
const csvHeader = Object.keys(results[0]).join(",") + "\n";
const csvContent = results
.map((row) => Object.values(row).join(","))
.join("\n");

return csvHeader + csvContent;
}
1 change: 1 addition & 0 deletions types/src/front/feature_flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const WHITELISTABLE_FEATURES = [
"mistral_next",
"structured_data",
"workspace_analytics",
"usage_data_api",
] as const;
export type WhitelistableFeature = (typeof WHITELISTABLE_FEATURES)[number];
export function isWhitelistableFeature(
Expand Down

0 comments on commit 8e22320

Please sign in to comment.