Skip to content

Commit f0bb90b

Browse files
feat(web): record service ping history and add usage report download (#1348)
* feat(web): record service ping history and add usage report download Record each service ping in a new ServicePingEvent table (activation code stripped) so offline deployments, which can't report usage to Lighthouse automatically, can download their usage history and email it to us. Adds a "Download usage report" button to the offline license settings card that exports the recorded pings as a JSON file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: add CHANGELOG entry for service ping history [#1348] Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * nit * feedback * feedback --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9320065 commit f0bb90b

9 files changed

Lines changed: 197 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Recorded service ping history locally and added a "Download usage report" button to the offline license settings page, so offline deployments can export their usage and send it to us. [#1348](https://github.com/sourcebot-dev/sourcebot/pull/1348)
12+
1013
### Fixed
1114
- Upgraded `@grpc/grpc-js` to `^1.14.4`. [#1315](https://github.com/sourcebot-dev/sourcebot/pull/1315)
1215
- Upgraded `vite` to `^8.0.16`. [#1313](https://github.com/sourcebot-dev/sourcebot/pull/1313)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- CreateTable
2+
CREATE TABLE "ServicePingEvent" (
3+
"id" TEXT NOT NULL,
4+
"payload" JSONB NOT NULL,
5+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
6+
"orgId" INTEGER NOT NULL,
7+
8+
CONSTRAINT "ServicePingEvent_pkey" PRIMARY KEY ("id")
9+
);
10+
11+
-- AddForeignKey
12+
ALTER TABLE "ServicePingEvent" ADD CONSTRAINT "ServicePingEvent_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;

packages/db/prisma/schema.prisma

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ model Org {
318318
mcpServers McpServer[]
319319
320320
license License?
321+
servicePingEvents ServicePingEvent[]
321322
}
322323

323324
model License {
@@ -358,6 +359,15 @@ model License {
358359
updatedAt DateTime @updatedAt
359360
}
360361

362+
model ServicePingEvent {
363+
id String @id @default(cuid())
364+
payload Json
365+
createdAt DateTime @default(now())
366+
367+
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
368+
orgId Int
369+
}
370+
361371
enum OrgRole {
362372
OWNER
363373
MEMBER

packages/shared/src/entitlements.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,18 @@ const getValidOnlineLicense = (_license: License | null): License | null => {
120120
return null;
121121
}
122122

123+
export const isValidOfflineLicenseActive = (): boolean => {
124+
return getValidOfflineLicense() !== null;
125+
}
126+
127+
export const isValidOnlineLicenseActive = (_license: License | null): boolean => {
128+
return getValidOnlineLicense(_license) !== null;
129+
}
130+
123131
export const isValidLicenseActive = (_license: License | null): boolean => {
124132
return (
125-
getValidOfflineLicense() !== null ||
126-
getValidOnlineLicense(_license) !== null
133+
isValidOfflineLicenseActive() ||
134+
isValidOnlineLicenseActive(_license)
127135
);
128136
}
129137

packages/shared/src/index.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export {
66
getEntitlements as _getEntitlements,
77
isAnonymousAccessAvailable as _isAnonymousAccessAvailable,
88
isValidLicenseActive as _isValidLicenseActive,
9+
isValidOfflineLicenseActive,
10+
isValidOnlineLicenseActive as _isValidOnlineLicenseActive,
911
getSeatCap,
1012
getOfflineLicenseMetadata,
1113
STALE_ONLINE_LICENSE_THRESHOLD_MS,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
'use server';
2+
3+
import { sew } from "@/middleware/sew";
4+
import { withAuth } from "@/middleware/withAuth";
5+
import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
6+
import { OrgRole } from "@sourcebot/db";
7+
import { ServiceError } from "@/lib/serviceError";
8+
9+
export interface ServicePingHistoryEntry {
10+
createdAt: string;
11+
payload: unknown;
12+
}
13+
14+
// Returns the recorded Service Ping history so offline deployments can export
15+
// it and send it back to us out-of-band (they can't reach Lighthouse directly).
16+
export const getServicePingHistory = async (): Promise<ServicePingHistoryEntry[] | ServiceError> => sew(() =>
17+
withAuth(async ({ org, role, prisma }) =>
18+
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
19+
const events = await prisma.servicePingEvent.findMany({
20+
where: { orgId: org.id },
21+
orderBy: { createdAt: 'asc' },
22+
});
23+
24+
return events.map((event) => ({
25+
createdAt: event.createdAt.toISOString(),
26+
payload: event.payload,
27+
}));
28+
})
29+
)
30+
);
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
'use client';
2+
3+
import { useCallback, useState } from "react";
4+
import { Download } from "lucide-react";
5+
import { LoadingButton } from "@/components/ui/loading-button";
6+
import { useToast } from "@/components/hooks/use-toast";
7+
import { isServiceError } from "@/lib/utils";
8+
import { getServicePingHistory } from "./actions";
9+
10+
export function DownloadServicePingHistoryButton() {
11+
const [isLoading, setIsLoading] = useState(false);
12+
const { toast } = useToast();
13+
14+
const handleDownload = useCallback(async () => {
15+
setIsLoading(true);
16+
try {
17+
const result = await getServicePingHistory();
18+
19+
if (isServiceError(result)) {
20+
toast({
21+
description: "Failed to export service ping history. Please try again.",
22+
variant: "destructive",
23+
});
24+
return;
25+
}
26+
27+
if (result.length === 0) {
28+
toast({
29+
description: "No service ping history has been recorded yet.",
30+
});
31+
return;
32+
}
33+
34+
const blob = new Blob([JSON.stringify(result, null, 2)], {
35+
type: "application/json",
36+
});
37+
const url = URL.createObjectURL(blob);
38+
const anchor = document.createElement("a");
39+
anchor.href = url;
40+
anchor.download = `${new Date().toISOString().slice(0, 10)}-usage-history.json`;
41+
document.body.appendChild(anchor);
42+
anchor.click();
43+
document.body.removeChild(anchor);
44+
URL.revokeObjectURL(url);
45+
} finally {
46+
setIsLoading(false);
47+
}
48+
}, [toast]);
49+
50+
return (
51+
<LoadingButton
52+
variant="outline"
53+
size="sm"
54+
onClick={handleDownload}
55+
loading={isLoading}
56+
>
57+
{!isLoading && <Download className="h-3.5 w-3.5" />}
58+
Download usage report
59+
</LoadingButton>
60+
);
61+
}

packages/web/src/app/(app)/settings/license/offlineLicenseCard.tsx

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { OfflineLicenseMetadata } from "@sourcebot/shared";
22
import { Badge } from "@/components/ui/badge";
33
import { SettingsCard } from "../components/settingsCard";
4+
import { DownloadServicePingHistoryButton } from "./downloadServicePingHistoryButton";
45

56
interface OfflineLicenseCardProps {
67
license: OfflineLicenseMetadata;
@@ -15,34 +16,47 @@ export function OfflineLicenseCard({ license, isExpired }: OfflineLicenseCardPro
1516

1617
return (
1718
<SettingsCard>
18-
<div className="flex items-center justify-between gap-6">
19-
<div className="flex flex-col gap-1">
20-
<div className="flex items-center gap-2">
21-
<p className="font-medium">Enterprise plan</p>
22-
{isExpired && (
23-
<Badge variant="outline" className="border-destructive/30 text-destructive">
24-
Expired
25-
</Badge>
26-
)}
27-
</div>
28-
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
29-
<code className="font-mono">{truncatedId}</code>
19+
<div className="flex flex-col gap-4">
20+
<div className="flex items-center justify-between gap-6">
21+
<div className="flex flex-col gap-1">
22+
<div className="flex items-center gap-2">
23+
<p className="font-medium">Enterprise plan</p>
24+
{isExpired && (
25+
<Badge variant="outline" className="border-destructive/30 text-destructive">
26+
Expired
27+
</Badge>
28+
)}
29+
</div>
30+
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
31+
<code className="font-mono">{truncatedId}</code>
32+
</div>
3033
</div>
31-
</div>
32-
<div className="flex items-center gap-12">
33-
{license.seats !== undefined && (
34+
<div className="flex items-center gap-12">
35+
{license.seats !== undefined && (
36+
<div className="flex flex-col items-end">
37+
<p className="text-xs text-muted-foreground">Billed seats</p>
38+
<p className="text-sm">{license.seats}</p>
39+
</div>
40+
)}
3441
<div className="flex flex-col items-end">
35-
<p className="text-xs text-muted-foreground">Billed seats</p>
36-
<p className="text-sm">{license.seats}</p>
42+
<p className="text-xs text-muted-foreground">
43+
{isExpired ? "Expired on" : "Expires on"}
44+
</p>
45+
<p className="text-sm">{formatDate(expiryDate)}</p>
3746
</div>
38-
)}
39-
<div className="flex flex-col items-end">
40-
<p className="text-xs text-muted-foreground">
41-
{isExpired ? "Expired on" : "Expires on"}
42-
</p>
43-
<p className="text-sm">{formatDate(expiryDate)}</p>
4447
</div>
4548
</div>
49+
<div className="flex items-center justify-between gap-6 border-t border-border pt-4">
50+
<p className="text-xs text-muted-foreground">
51+
Your instance doesn&apos;t report usage automatically. Usage data must be
52+
manually sent to{" "}
53+
<a href="mailto:ar@sourcebot.dev" className="text-primary hover:underline">
54+
ar@sourcebot.dev
55+
</a>
56+
.
57+
</p>
58+
<DownloadServicePingHistoryButton />
59+
</div>
4660
</div>
4761
</SettingsCard>
4862
);

packages/web/src/features/billing/servicePing.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import { existsSync } from "fs";
22
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
33
import { isServiceError } from "@/lib/utils";
44
import { __unsafePrisma } from "@/prisma";
5-
import { createLogger, decryptActivationCode, env, SOURCEBOT_VERSION } from "@sourcebot/shared";
5+
import {
6+
createLogger,
7+
decryptActivationCode,
8+
env,
9+
SOURCEBOT_VERSION,
10+
isValidOfflineLicenseActive
11+
} from "@sourcebot/shared";
612
import { client } from "./client";
713
import { ServicePingRequest } from "./types";
814
import { ServiceErrorException } from "@/lib/serviceError";
@@ -83,6 +89,13 @@ export const syncWithLighthouse = async (orgId: number) => {
8389
...(activationCode && { activationCode }),
8490
};
8591

92+
await recordServicePingInDB(orgId, payload);
93+
94+
if (isValidOfflineLicenseActive()) {
95+
logger.debug('Skipping service ping: active offline license detected.');
96+
return;
97+
}
98+
8699
const response = await client.ping(payload);
87100
if (isServiceError(response)) {
88101
logger.error(`Service ping failed:\n ${JSON.stringify(response, null, 2)}`)
@@ -172,3 +185,21 @@ const inferDeploymentType = (): string => {
172185
}
173186
return 'other';
174187
};
188+
189+
const recordServicePingInDB = async (orgId: number, payload: ServicePingRequest) => {
190+
// Strip the activation code before persisting.
191+
const { activationCode: _activationCode, ...sanitizedPayload } = payload;
192+
193+
try {
194+
await __unsafePrisma.servicePingEvent.create({
195+
data: {
196+
orgId,
197+
payload: sanitizedPayload,
198+
},
199+
});
200+
} catch (error) {
201+
// Recording the ping is best-effort: a failure here must not prevent
202+
// the actual ping from being sent to Lighthouse.
203+
logger.error(`Failed to record service ping in database:\n ${error}`);
204+
}
205+
};

0 commit comments

Comments
 (0)