Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1190,6 +1190,9 @@ const EnvironmentSchema = z
CLICKHOUSE_LOGS_DETAIL_MAX_THREADS: z.coerce.number().int().default(2),
CLICKHOUSE_LOGS_DETAIL_MAX_EXECUTION_TIME: z.coerce.number().int().default(60),

// Query feature flag
QUERY_FEATURE_ENABLED: z.string().default("1"),

// Query page ClickHouse limits (for TSQL queries)
QUERY_CLICKHOUSE_MAX_EXECUTION_TIME: z.coerce.number().int().default(10),
QUERY_CLICKHOUSE_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_073_741_824), // 1GB in bytes
Expand Down
16 changes: 11 additions & 5 deletions apps/webapp/app/presenters/OrganizationsPresenter.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { RuntimeEnvironment, type PrismaClient } from "@trigger.dev/database";
import type { RuntimeEnvironment, PrismaClient } from "@trigger.dev/database";
import { redirect } from "remix-typedjson";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
Expand All @@ -10,7 +10,7 @@ import {
} from "./SelectBestEnvironmentPresenter.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
import { validatePartialFeatureFlags } from "~/v3/featureFlags.server";
import { flags, validatePartialFeatureFlags } from "~/v3/featureFlags.server";

export class OrganizationsPresenter {
#prismaClient: PrismaClient;
Expand Down Expand Up @@ -153,18 +153,24 @@ export class OrganizationsPresenter {
},
});

// Get global feature flags (no overrides or defaults)
const globalFlags = await flags();

return orgs.map((org) => {
const flagsResult = org.featureFlags
const orgFlagsResult = org.featureFlags
? validatePartialFeatureFlags(org.featureFlags as Record<string, unknown>)
: ({ success: false } as const);
const flags = flagsResult.success ? flagsResult.data : {};
const orgFlags = orgFlagsResult.success ? orgFlagsResult.data : {};

// Combine global flags with org flags (org flags win)
const combinedFlags = { ...globalFlags, ...orgFlags };

return {
id: org.id,
slug: org.slug,
title: org.title,
avatar: parseAvatar(org.avatar, defaultAvatar),
featureFlags: flags,
featureFlags: combinedFlags,
projects: org.projects.map((project) => ({
id: project.id,
slug: project.slug,
Expand Down
4 changes: 2 additions & 2 deletions apps/webapp/app/presenters/v3/RegionsPresenter.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type Project } from "~/models/project.server";
import { type User } from "~/models/user.server";
import { FEATURE_FLAG, makeFlags } from "~/v3/featureFlags.server";
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
import { BasePresenter } from "./basePresenter.server";
import { getCurrentPlan } from "~/services/platform.v3.server";

Expand Down Expand Up @@ -48,7 +48,7 @@ export class RegionsPresenter extends BasePresenter {
throw new Error("Project not found");
}

const getFlag = makeFlags(this._replica);
const getFlag = makeFlag(this._replica);
const defaultWorkerInstanceGroupId = await getFlag({
key: FEATURE_FLAG.defaultWorkerInstanceGroupId,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ import { executeQuery, type QueryScope } from "~/services/queryService.server";
import { requireUser } from "~/services/session.server";
import { downloadFile, rowsToCSV, rowsToJSON } from "~/utils/dataExport";
import { EnvironmentParamSchema, organizationBillingPath } from "~/utils/pathBuilder";
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server";
import { canAccessQuery } from "~/v3/canAccessQuery.server";
import { querySchemas } from "~/v3/querySchemas";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { QueryHelpSidebar } from "./QueryHelpSidebar";
Expand All @@ -91,40 +91,6 @@ function toISOString(value: Date | string): string {
return value.toISOString();
}

async function hasQueryAccess(
userId: string,
isAdmin: boolean,
isImpersonating: boolean,
organizationSlug: string
): Promise<boolean> {
if (isAdmin || isImpersonating) {
return true;
}

// Check organization feature flags
const organization = await prisma.organization.findFirst({
where: {
slug: organizationSlug,
members: { some: { userId } },
},
select: {
featureFlags: true,
},
});

if (!organization?.featureFlags) {
return false;
}

const flags = organization.featureFlags as Record<string, unknown>;
const hasQueryAccessResult = validateFeatureFlagValue(
FEATURE_FLAG.hasQueryAccess,
flags.hasQueryAccess
);

return hasQueryAccessResult.success && hasQueryAccessResult.data === true;
}

const scopeOptions = [
{ value: "environment", label: "Environment" },
{ value: "project", label: "Project" },
Expand All @@ -135,12 +101,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);

const canAccess = await hasQueryAccess(
user.id,
user.admin,
user.isImpersonating,
organizationSlug
);
const canAccess = await canAccessQuery({
userId: user.id,
isAdmin: user.admin,
isImpersonating: user.isImpersonating,
organizationSlug,
});
if (!canAccess) {
throw redirect("/");
}
Expand Down Expand Up @@ -200,12 +166,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);

const canAccess = await hasQueryAccess(
user.id,
user.admin,
user.isImpersonating,
organizationSlug
);
const canAccess = await canAccessQuery({
userId: user.id,
isAdmin: user.admin,
isImpersonating: user.isImpersonating,
organizationSlug,
});
if (!canAccess) {
return typedjson(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import parseDuration from "parse-duration";
import { z } from "zod";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
import { type PrismaClient, type PrismaClientOrTransaction } from "~/db.server";
import { FEATURE_FLAG, makeFlags } from "~/v3/featureFlags.server";
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
import { startActiveSpan } from "~/v3/tracer.server";
import { logger } from "../logger.server";
import { ClickHouseRunsRepository } from "./clickhouseRunsRepository.server";
Expand Down Expand Up @@ -163,7 +163,7 @@ export class RunsRepository implements IRunsRepository {

async #getRepository(): Promise<IRunsRepository> {
return startActiveSpan("runsRepository.getRepository", async (span) => {
const getFlag = makeFlags(this.options.prisma);
const getFlag = makeFlag(this.options.prisma);
const runsListRepository = await getFlag({
key: FEATURE_FLAG.runsListRepository,
defaultValue: this.defaultRepository,
Expand Down
47 changes: 47 additions & 0 deletions apps/webapp/app/v3/canAccessQuery.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";

export async function canAccessQuery(options: {
userId: string;
isAdmin: boolean;
isImpersonating: boolean;
organizationSlug: string;
}): Promise<boolean> {
const { userId, isAdmin, isImpersonating, organizationSlug } = options;

// 1. If it's on then we have access
const globallyEnabled = env.QUERY_FEATURE_ENABLED === "1";
if (globallyEnabled) {
return true;
}

// 2. Admins always have access
if (isAdmin || isImpersonating) {
return true;
}

// 3. Check if org/global feature flag is on
const org = await prisma.organization.findFirst({
where: {
slug: organizationSlug,
members: { some: { userId } },
},
select: {
featureFlags: true,
},
});

const flag = makeFlag();
const flagResult = await flag({
key: FEATURE_FLAG.hasQueryAccess,
defaultValue: false,
overrides: (org?.featureFlags as Record<string, unknown>) ?? {},
});
if (flagResult) {
return true;
}

// 4. Not enabled anywhere
return false;
}
24 changes: 12 additions & 12 deletions apps/webapp/app/v3/eventRepository/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import {
clickhouseEventRepositoryV2,
} from "./clickhouseEventRepositoryInstance.server";
import { IEventRepository, TraceEventOptions } from "./eventRepository.types";
import { prisma } from "~/db.server";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { FEATURE_FLAG, flags } from "../featureFlags.server";
import { FEATURE_FLAG, flag } from "../featureFlags.server";
import { getTaskEventStore } from "../taskEventStore.server";

export function resolveEventRepositoryForStore(store: string | undefined): IEventRepository {
Expand All @@ -24,13 +24,13 @@ export function resolveEventRepositoryForStore(store: string | undefined): IEven
return eventRepository;
}

export const EVENT_STORE_TYPES = {
POSTGRES: "postgres",
CLICKHOUSE: "clickhouse",
CLICKHOUSE_V2: "clickhouse_v2",
} as const;
export const EVENT_STORE_TYPES = {
POSTGRES: "postgres",
CLICKHOUSE: "clickhouse",
CLICKHOUSE_V2: "clickhouse_v2",
} as const;

export type EventStoreType = typeof EVENT_STORE_TYPES[keyof typeof EVENT_STORE_TYPES];
export type EventStoreType = (typeof EVENT_STORE_TYPES)[keyof typeof EVENT_STORE_TYPES];

export async function getConfiguredEventRepository(
organizationId: string
Expand Down Expand Up @@ -122,21 +122,21 @@ export async function getV3EventRepository(
async function resolveTaskEventRepositoryFlag(
featureFlags: Record<string, unknown> | undefined
): Promise<"clickhouse" | "clickhouse_v2" | "postgres"> {
const flag = await flags({
const flagResult = await flag({
key: FEATURE_FLAG.taskEventRepository,
defaultValue: env.EVENT_REPOSITORY_DEFAULT_STORE,
overrides: featureFlags,
});

if (flag === "clickhouse_v2") {
if (flagResult === "clickhouse_v2") {
return "clickhouse_v2";
}

if (flag === "clickhouse") {
if (flagResult === "clickhouse") {
return "clickhouse";
}

return flag;
return flagResult;
}

export async function recordRunDebugLog(
Expand Down
69 changes: 60 additions & 9 deletions apps/webapp/app/v3/featureFlags.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ export type FlagsOptions<T extends FeatureFlagKey> = {
overrides?: Record<string, unknown>;
};

export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) {
function flags<T extends FeatureFlagKey>(
export function makeFlag(_prisma: PrismaClientOrTransaction = prisma) {
function flag<T extends FeatureFlagKey>(
opts: FlagsOptions<T> & { defaultValue: z.infer<(typeof FeatureFlagCatalog)[T]> }
): Promise<z.infer<(typeof FeatureFlagCatalog)[T]>>;
function flags<T extends FeatureFlagKey>(
function flag<T extends FeatureFlagKey>(
opts: FlagsOptions<T>
): Promise<z.infer<(typeof FeatureFlagCatalog)[T]> | undefined>;
async function flags<T extends FeatureFlagKey>(
async function flag<T extends FeatureFlagKey>(
opts: FlagsOptions<T>
): Promise<z.infer<(typeof FeatureFlagCatalog)[T]> | undefined> {
const value = await _prisma.featureFlag.findUnique({
Expand Down Expand Up @@ -60,11 +60,11 @@ export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) {
return parsed.data;
}

return flags;
return flag;
}

export function makeSetFlags(_prisma: PrismaClientOrTransaction = prisma) {
return async function setFlags<T extends FeatureFlagKey>(
export function makeSetFlag(_prisma: PrismaClientOrTransaction = prisma) {
return async function setFlag<T extends FeatureFlagKey>(
opts: FlagsOptions<T> & { value: z.infer<(typeof FeatureFlagCatalog)[T]> }
): Promise<void> {
await _prisma.featureFlag.upsert({
Expand All @@ -82,8 +82,59 @@ export function makeSetFlags(_prisma: PrismaClientOrTransaction = prisma) {
};
}

export type AllFlagsOptions = {
defaultValues?: Partial<FeatureFlagCatalog>;
overrides?: Record<string, unknown>;
};

export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) {
return async function flags(options?: AllFlagsOptions): Promise<Partial<FeatureFlagCatalog>> {
const rows = await _prisma.featureFlag.findMany();

// Build a map of key -> value from database
const dbValues = new Map<string, unknown>();
for (const row of rows) {
dbValues.set(row.key, row.value);
}

const result: Partial<FeatureFlagCatalog> = {};

// Process each flag in the catalog
for (const key of Object.keys(FeatureFlagCatalog) as FeatureFlagKey[]) {
const schema = FeatureFlagCatalog[key];

// Priority: overrides > database > defaultValues
if (options?.overrides?.[key] !== undefined) {
const parsed = schema.safeParse(options.overrides[key]);
if (parsed.success) {
(result as any)[key] = parsed.data;
continue;
}
}

if (dbValues.has(key)) {
const parsed = schema.safeParse(dbValues.get(key));
if (parsed.success) {
(result as any)[key] = parsed.data;
continue;
}
}

if (options?.defaultValues?.[key] !== undefined) {
const parsed = schema.safeParse(options.defaultValues[key]);
if (parsed.success) {
(result as any)[key] = parsed.data;
}
}
}

return result;
};
}

export const flag = makeFlag();
export const flags = makeFlags();
export const setFlags = makeSetFlags();
export const setFlag = makeSetFlag();

// Create a Zod schema from the existing catalog
export const FeatureFlagCatalogSchema = z.object(FeatureFlagCatalog);
Expand Down Expand Up @@ -112,7 +163,7 @@ export function makeSetMultipleFlags(_prisma: PrismaClientOrTransaction = prisma
return async function setMultipleFlags(
flags: Partial<z.infer<typeof FeatureFlagCatalogSchema>>
): Promise<{ key: string; value: any }[]> {
const setFlag = makeSetFlags(_prisma);
const setFlag = makeSetFlag(_prisma);
const updatedFlags: { key: string; value: any }[] = [];

for (const [key, value] of Object.entries(flags)) {
Expand Down
Loading