Skip to content

Commit 42f53b1

Browse files
authored
feat(deployments): stream build server logs (#2663)
* Use read-only project-scoped s2 tokens for streaming deployment logs * Add http2 to remix polyfills Needed for using s2 client-side. * Stream build-server logs in the deployment details page * Disable 12-hour format in the DateTime component * Enable collapsing the logs panel * Auto-collapse logs for succesful/timedout/queued deployments * Make S2 env vars optional * Show the logs section only for gh-triggered deployments * Cache s2 access tokens in redis * Reset streaming state * Expose 12h format as a param for the Datetime components
1 parent 9fdf91a commit 42f53b1

File tree

9 files changed

+625
-127
lines changed

9 files changed

+625
-127
lines changed

apps/webapp/app/components/primitives/DateTime.tsx

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ type DateTimeProps = {
1313
includeTime?: boolean;
1414
showTimezone?: boolean;
1515
showTooltip?: boolean;
16+
hideDate?: boolean;
1617
previousDate?: Date | string | null; // Add optional previous date for comparison
18+
hour12?: boolean;
1719
};
1820

1921
export const DateTime = ({
@@ -23,6 +25,7 @@ export const DateTime = ({
2325
includeTime = true,
2426
showTimezone = false,
2527
showTooltip = true,
28+
hour12 = true,
2629
}: DateTimeProps) => {
2730
const locales = useLocales();
2831
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
@@ -50,7 +53,8 @@ export const DateTime = ({
5053
timeZone ?? localTimeZone,
5154
locales,
5255
includeSeconds,
53-
includeTime
56+
includeTime,
57+
hour12
5458
).replace(/\s/g, String.fromCharCode(32))}
5559
{showTimezone ? ` (${timeZone ?? "UTC"})` : null}
5660
</Fragment>
@@ -66,7 +70,8 @@ export function formatDateTime(
6670
timeZone: string,
6771
locales: string[],
6872
includeSeconds: boolean,
69-
includeTime: boolean
73+
includeTime: boolean,
74+
hour12: boolean = true
7075
): string {
7176
return new Intl.DateTimeFormat(locales, {
7277
year: "numeric",
@@ -76,6 +81,7 @@ export function formatDateTime(
7681
minute: includeTime ? "numeric" : undefined,
7782
second: includeTime && includeSeconds ? "numeric" : undefined,
7883
timeZone,
84+
hour12,
7985
}).format(date);
8086
}
8187

@@ -122,7 +128,7 @@ export function formatDateTimeISO(date: Date, timeZone: string): string {
122128
}
123129

124130
// New component that only shows date when it changes
125-
export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: DateTimeProps) => {
131+
export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC", hour12 = true }: DateTimeProps) => {
126132
const locales = useLocales();
127133
const realDate = typeof date === "string" ? new Date(date) : date;
128134
const realPrevDate = previousDate
@@ -132,8 +138,8 @@ export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: D
132138
: null;
133139

134140
// Initial formatted values
135-
const initialTimeOnly = formatTimeOnly(realDate, timeZone, locales);
136-
const initialWithDate = formatSmartDateTime(realDate, timeZone, locales);
141+
const initialTimeOnly = formatTimeOnly(realDate, timeZone, locales, hour12);
142+
const initialWithDate = formatSmartDateTime(realDate, timeZone, locales, hour12);
137143

138144
// State for the formatted time
139145
const [formattedDateTime, setFormattedDateTime] = useState<string>(
@@ -150,10 +156,10 @@ export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: D
150156
// Format with appropriate function
151157
setFormattedDateTime(
152158
showDatePart
153-
? formatSmartDateTime(realDate, userTimeZone, locales)
154-
: formatTimeOnly(realDate, userTimeZone, locales)
159+
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
160+
: formatTimeOnly(realDate, userTimeZone, locales, hour12)
155161
);
156-
}, [locales, realDate, realPrevDate]);
162+
}, [locales, realDate, realPrevDate, hour12]);
157163

158164
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
159165
};
@@ -168,7 +174,7 @@ function isSameDay(date1: Date, date2: Date): boolean {
168174
}
169175

170176
// Format with date and time
171-
function formatSmartDateTime(date: Date, timeZone: string, locales: string[]): string {
177+
function formatSmartDateTime(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
172178
return new Intl.DateTimeFormat(locales, {
173179
month: "short",
174180
day: "numeric",
@@ -178,18 +184,20 @@ function formatSmartDateTime(date: Date, timeZone: string, locales: string[]): s
178184
timeZone,
179185
// @ts-ignore fractionalSecondDigits works in most modern browsers
180186
fractionalSecondDigits: 3,
187+
hour12,
181188
}).format(date);
182189
}
183190

184191
// Format time only
185-
function formatTimeOnly(date: Date, timeZone: string, locales: string[]): string {
192+
function formatTimeOnly(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
186193
return new Intl.DateTimeFormat(locales, {
187-
hour: "numeric",
194+
hour: "2-digit",
188195
minute: "numeric",
189196
second: "numeric",
190197
timeZone,
191198
// @ts-ignore fractionalSecondDigits works in most modern browsers
192199
fractionalSecondDigits: 3,
200+
hour12,
193201
}).format(date);
194202
}
195203

@@ -198,6 +206,8 @@ export const DateTimeAccurate = ({
198206
timeZone = "UTC",
199207
previousDate = null,
200208
showTooltip = true,
209+
hideDate = false,
210+
hour12 = true,
201211
}: DateTimeProps) => {
202212
const locales = useLocales();
203213
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
@@ -214,11 +224,13 @@ export const DateTimeAccurate = ({
214224
}, []);
215225

216226
// Smart formatting based on whether date changed
217-
const formattedDateTime = realPrevDate
227+
const formattedDateTime = hideDate
228+
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
229+
: realPrevDate
218230
? isSameDay(realDate, realPrevDate)
219-
? formatTimeOnly(realDate, localTimeZone, locales)
220-
: formatDateTimeAccurate(realDate, localTimeZone, locales)
221-
: formatDateTimeAccurate(realDate, localTimeZone, locales);
231+
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
232+
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12)
233+
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12);
222234

223235
if (!showTooltip)
224236
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
@@ -241,7 +253,7 @@ export const DateTimeAccurate = ({
241253
);
242254
};
243255

244-
function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[]): string {
256+
function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
245257
const formattedDateTime = new Intl.DateTimeFormat(locales, {
246258
month: "short",
247259
day: "numeric",
@@ -251,33 +263,35 @@ function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[])
251263
timeZone,
252264
// @ts-ignore fractionalSecondDigits works in most modern browsers
253265
fractionalSecondDigits: 3,
266+
hour12,
254267
}).format(date);
255268

256269
return formattedDateTime;
257270
}
258271

259-
export const DateTimeShort = ({ date, timeZone = "UTC" }: DateTimeProps) => {
272+
export const DateTimeShort = ({ date, timeZone = "UTC", hour12 = true }: DateTimeProps) => {
260273
const locales = useLocales();
261274
const realDate = typeof date === "string" ? new Date(date) : date;
262-
const initialFormattedDateTime = formatDateTimeShort(realDate, timeZone, locales);
275+
const initialFormattedDateTime = formatDateTimeShort(realDate, timeZone, locales, hour12);
263276
const [formattedDateTime, setFormattedDateTime] = useState<string>(initialFormattedDateTime);
264277

265278
useEffect(() => {
266279
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
267-
setFormattedDateTime(formatDateTimeShort(realDate, resolvedOptions.timeZone, locales));
268-
}, [locales, realDate]);
280+
setFormattedDateTime(formatDateTimeShort(realDate, resolvedOptions.timeZone, locales, hour12));
281+
}, [locales, realDate, hour12]);
269282

270283
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
271284
};
272285

273-
function formatDateTimeShort(date: Date, timeZone: string, locales: string[]): string {
286+
function formatDateTimeShort(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
274287
const formattedDateTime = new Intl.DateTimeFormat(locales, {
275288
hour: "numeric",
276289
minute: "numeric",
277290
second: "numeric",
278291
timeZone,
279292
// @ts-ignore fractionalSecondDigits works in most modern browsers
280293
fractionalSecondDigits: 3,
294+
hour12,
281295
}).format(date);
282296

283297
return formattedDateTime;

apps/webapp/app/components/primitives/Paragraph.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ const paragraphVariants = {
1717
text: "font-sans text-sm font-normal text-text-bright",
1818
spacing: "mb-2",
1919
},
20+
"small/dimmed": {
21+
text: "font-sans text-sm font-normal text-text-dimmed",
22+
spacing: "mb-2",
23+
},
2024
"extra-small": {
2125
text: "font-sans text-xs font-normal text-text-dimmed",
2226
spacing: "mb-1.5",
@@ -25,6 +29,14 @@ const paragraphVariants = {
2529
text: "font-sans text-xs font-normal text-text-bright",
2630
spacing: "mb-1.5",
2731
},
32+
"extra-small/dimmed": {
33+
text: "font-sans text-xs font-normal text-text-dimmed",
34+
spacing: "mb-1.5",
35+
},
36+
"extra-small/dimmed/mono": {
37+
text: "font-mono text-xs font-normal text-text-dimmed",
38+
spacing: "mb-1.5",
39+
},
2840
"extra-small/mono": {
2941
text: "font-mono text-xs font-normal text-text-dimmed",
3042
spacing: "mb-1.5",

apps/webapp/app/env.server.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,27 @@ const GithubAppEnvSchema = z.preprocess(
2525
])
2626
);
2727

28+
// eventually we can make all S2 env vars required once the S2 OSS version is out
29+
const S2EnvSchema = z.preprocess(
30+
(val) => {
31+
const obj = val as any;
32+
if (!obj || !obj.S2_ENABLED) {
33+
return { ...obj, S2_ENABLED: "0" };
34+
}
35+
return obj;
36+
},
37+
z.discriminatedUnion("S2_ENABLED", [
38+
z.object({
39+
S2_ENABLED: z.literal("1"),
40+
S2_ACCESS_TOKEN: z.string(),
41+
S2_DEPLOYMENT_LOGS_BASIN_NAME: z.string(),
42+
}),
43+
z.object({
44+
S2_ENABLED: z.literal("0"),
45+
}),
46+
])
47+
);
48+
2849
const EnvironmentSchema = z
2950
.object({
3051
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
@@ -1202,7 +1223,8 @@ const EnvironmentSchema = z
12021223

12031224
VERY_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().optional(),
12041225
})
1205-
.and(GithubAppEnvSchema);
1226+
.and(GithubAppEnvSchema)
1227+
.and(S2EnvSchema);
12061228

12071229
export type Environment = z.infer<typeof EnvironmentSchema>;
12081230
export const env = EnvironmentSchema.parse(process.env);

apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,34 @@
11
import {
22
DeploymentErrorData,
33
ExternalBuildData,
4+
logger,
45
prepareDeploymentError,
56
} from "@trigger.dev/core/v3";
6-
import { RuntimeEnvironment, type WorkerDeployment } from "@trigger.dev/database";
7+
import { type RuntimeEnvironment, type WorkerDeployment } from "@trigger.dev/database";
78
import { type PrismaClient, prisma } from "~/db.server";
89
import { type Organization } from "~/models/organization.server";
910
import { type Project } from "~/models/project.server";
1011
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
1112
import { type User } from "~/models/user.server";
1213
import { getUsername } from "~/utils/username";
1314
import { processGitMetadata } from "./BranchesPresenter.server";
15+
import { S2 } from "@s2-dev/streamstore";
16+
import { env } from "~/env.server";
17+
import { createRedisClient } from "~/redis.server";
18+
import { tryCatch } from "@trigger.dev/core";
19+
20+
const S2_TOKEN_KEY_PREFIX = "s2-token:project:";
21+
22+
const s2TokenRedis = createRedisClient("s2-token-cache", {
23+
host: env.CACHE_REDIS_HOST,
24+
port: env.CACHE_REDIS_PORT,
25+
username: env.CACHE_REDIS_USERNAME,
26+
password: env.CACHE_REDIS_PASSWORD,
27+
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
28+
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
29+
});
30+
31+
const s2 = env.S2_ENABLED === "1" ? new S2({ accessToken: env.S2_ACCESS_TOKEN }) : undefined;
1432

1533
export type ErrorData = {
1634
name: string;
@@ -43,6 +61,7 @@ export class DeploymentPresenter {
4361
select: {
4462
id: true,
4563
organizationId: true,
64+
externalRef: true,
4665
},
4766
where: {
4867
slug: projectSlug,
@@ -138,11 +157,29 @@ export class DeploymentPresenter {
138157
},
139158
});
140159

160+
const gitMetadata = processGitMetadata(deployment.git);
161+
141162
const externalBuildData = deployment.externalBuildData
142163
? ExternalBuildData.safeParse(deployment.externalBuildData)
143164
: undefined;
144165

166+
let s2Logs = undefined;
167+
if (env.S2_ENABLED === "1" && gitMetadata?.source === "trigger_github_app") {
168+
const [error, accessToken] = await tryCatch(this.getS2AccessToken(project.externalRef));
169+
170+
if (error) {
171+
logger.error("Failed getting S2 access token", { error });
172+
} else {
173+
s2Logs = {
174+
basin: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
175+
stream: `projects/${project.externalRef}/deployments/${deployment.shortCode}`,
176+
accessToken,
177+
};
178+
}
179+
}
180+
145181
return {
182+
s2Logs,
146183
deployment: {
147184
id: deployment.id,
148185
shortCode: deployment.shortCode,
@@ -178,11 +215,46 @@ export class DeploymentPresenter {
178215
errorData: DeploymentPresenter.prepareErrorData(deployment.errorData),
179216
isBuilt: !!deployment.builtAt,
180217
type: deployment.type,
181-
git: processGitMetadata(deployment.git),
218+
git: gitMetadata,
182219
},
183220
};
184221
}
185222

223+
private async getS2AccessToken(projectRef: string): Promise<string> {
224+
if (env.S2_ENABLED !== "1" || !s2) {
225+
throw new Error("Failed getting S2 access token: S2 is not enabled");
226+
}
227+
228+
const redisKey = `${S2_TOKEN_KEY_PREFIX}${projectRef}`;
229+
const cachedToken = await s2TokenRedis.get(redisKey);
230+
231+
if (cachedToken) {
232+
return cachedToken;
233+
}
234+
235+
const { access_token: accessToken } = await s2.accessTokens.issue({
236+
id: `${projectRef}-${new Date().getTime()}`,
237+
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour
238+
scope: {
239+
ops: ["read"],
240+
basins: {
241+
exact: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
242+
},
243+
streams: {
244+
prefix: `projects/${projectRef}/deployments/`,
245+
},
246+
},
247+
});
248+
249+
await s2TokenRedis.setex(
250+
redisKey,
251+
59 * 60, // slightly shorter than the token validity period
252+
accessToken
253+
);
254+
255+
return accessToken;
256+
}
257+
186258
public static prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined {
187259
if (!errorData) {
188260
return;

0 commit comments

Comments
 (0)