Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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;
}
18 changes: 9 additions & 9 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,7 +122,7 @@ export async function getV3EventRepository(
async function resolveTaskEventRepositoryFlag(
featureFlags: Record<string, unknown> | undefined
): Promise<"clickhouse" | "clickhouse_v2" | "postgres"> {
const flag = await flags({
const flag = await flag({
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated
key: FEATURE_FLAG.taskEventRepository,
defaultValue: env.EVENT_REPOSITORY_DEFAULT_STORE,
overrides: featureFlags,
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
Loading