Skip to content

Commit d50f6b8

Browse files
authored
[OPIK-5691] [BE][FE] feat: add eval suite analytics events (#6326)
* [OPIK-5691] [BE][FE] feat: add eval suite analytics events Add three analytics events for the Eval Suite launch dashboards tracked in OPIK-5245: Backend: - opik_eval_suite_created: fired when a TEST_SUITE dataset is created (DatasetsResource), capturing both UI and SDK creation paths. - opik_eval_suite_run: fired when an experiment is created against a TEST_SUITE dataset (ExperimentService), capturing reuse and run frequency. Wrapped in try/catch to avoid disrupting experiment creation if analytics fails. Frontend: - opik_eval_suite_ui_configured: fired from the creation dialog (useDatasetForm) with UI-specific properties (CSV upload, assertions, runs_per_item) not available at the BE endpoint. Note: item_count was omitted from opik_eval_suite_run because the dataset enrichment needed to fetch it is too heavy for an analytics side-effect. Can be added later with a lightweight count query if the "test cases per suite" metric proves critical. * fix(test): add AnalyticsService mock to test constructors DatasetsResourceIntegrationTest and ExperimentServiceTest manually construct the resource/service with explicit args — add the missing AnalyticsService mock parameter to match the updated constructors. * fix(analytics): add opik_ prefix to BE event names Event names in code should already include the opik_ prefix per the analytics-instrumentation skill doc, even though AnalyticsService auto-prepends it at runtime. Aligns BE naming with the FE convention. * fix(analytics): offload blocking dataset lookup to boundedElastic scheduler Wraps the datasetService.getById() call in trackEvalSuiteRunIfApplicable with Schedulers.boundedElastic().schedule() to prevent thread starvation in the doOnSuccess callback. * fix(analytics): pass userName explicitly to offloaded eval_suite_run event The trackEvalSuiteRunIfApplicable call runs on a boundedElastic thread outside the request scope, so resolveIdentity() would fall back to the anonymous installation ID. Capture userName before scheduling and use the explicit-identity trackEvent overload to ensure PostHog funnels work.
1 parent 4c84e4f commit d50f6b8

6 files changed

Lines changed: 56 additions & 2 deletions

File tree

apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/priv/DatasetsResource.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import com.comet.opik.api.DatasetItemChanges;
1717
import com.comet.opik.api.DatasetItemStreamRequest;
1818
import com.comet.opik.api.DatasetItemsDelete;
19+
import com.comet.opik.api.DatasetType;
1920
import com.comet.opik.api.DatasetUpdate;
2021
import com.comet.opik.api.DatasetVersion;
2122
import com.comet.opik.api.ExperimentItem;
@@ -43,6 +44,7 @@
4344
import com.comet.opik.infrastructure.auth.RequestContext;
4445
import com.comet.opik.infrastructure.auth.RequiredPermissions;
4546
import com.comet.opik.infrastructure.auth.WorkspaceUserPermission;
47+
import com.comet.opik.infrastructure.bi.AnalyticsService;
4648
import com.comet.opik.infrastructure.ratelimit.RateLimited;
4749
import com.comet.opik.utils.FileNameUtils;
4850
import com.comet.opik.utils.RetryUtils;
@@ -90,6 +92,7 @@
9092
import java.io.InputStream;
9193
import java.net.URI;
9294
import java.util.List;
95+
import java.util.Map;
9396
import java.util.Optional;
9497
import java.util.Set;
9598
import java.util.UUID;
@@ -120,6 +123,7 @@ public class DatasetsResource {
120123
private final @NonNull CsvDatasetItemProcessor csvProcessor;
121124
private final @NonNull FeatureFlags featureFlags;
122125
private final @NonNull CsvDatasetExportService csvExportService;
126+
private final @NonNull AnalyticsService analyticsService;
123127

124128
@GET
125129
@Path("/{id}")
@@ -197,6 +201,13 @@ public Response createDataset(
197201
log.info("Created dataset with name '{}', id '{}', on workspace_id '{}'", savedDataset.name(),
198202
savedDataset.id(), workspaceId);
199203

204+
if (savedDataset.type() == DatasetType.TEST_SUITE) {
205+
analyticsService.trackEvent("opik_eval_suite_created", Map.of(
206+
"eval_suite_id", savedDataset.id().toString(),
207+
"eval_suite_name", savedDataset.name(),
208+
"project_id", String.valueOf(savedDataset.projectId())));
209+
}
210+
200211
URI uri = uriInfo.getAbsolutePathBuilder().path("/%s".formatted(savedDataset.id().toString())).build();
201212
return Response.created(uri).build();
202213
}

apps/opik-backend/src/main/java/com/comet/opik/domain/ExperimentService.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.comet.opik.api.BiInformationResponse;
55
import com.comet.opik.api.Dataset;
66
import com.comet.opik.api.DatasetLastExperimentCreated;
7+
import com.comet.opik.api.DatasetType;
78
import com.comet.opik.api.DatasetVersion;
89
import com.comet.opik.api.ExecutionPolicy;
910
import com.comet.opik.api.Experiment;
@@ -32,6 +33,7 @@
3233
import com.comet.opik.infrastructure.FeatureFlags;
3334
import com.comet.opik.infrastructure.OpikConfiguration;
3435
import com.comet.opik.infrastructure.auth.RequestContext;
36+
import com.comet.opik.infrastructure.bi.AnalyticsService;
3537
import com.google.common.base.Preconditions;
3638
import com.google.common.eventbus.EventBus;
3739
import io.opentelemetry.instrumentation.annotations.WithSpan;
@@ -91,6 +93,7 @@ private record ResolvedVersion(UUID versionId, ExecutionPolicy executionPolicy)
9193
private final @NonNull ExperimentGroupEnricher experimentGroupEnricher;
9294
private final @NonNull ExperimentAggregatesService experimentAggregatesService;
9395
private final @NonNull ExperimentAggregationPublisher experimentAggregationPublisher;
96+
private final @NonNull AnalyticsService analyticsService;
9497

9598
@WithSpan
9699
public Mono<ExperimentPage> find(
@@ -690,6 +693,24 @@ private void postExperimentCreatedEvent(Experiment partialExperiment, String wor
690693
Optional.ofNullable(partialExperiment.type()).orElse(ExperimentType.REGULAR)));
691694
log.info("Posted experiment created event for experiment id '{}', datasetId '{}', workspaceId '{}'",
692695
partialExperiment.id(), partialExperiment.datasetId(), workspaceId);
696+
697+
trackEvalSuiteRunIfApplicable(partialExperiment, workspaceId, userName);
698+
}
699+
700+
private void trackEvalSuiteRunIfApplicable(Experiment experiment, String workspaceId, String userName) {
701+
Schedulers.boundedElastic().schedule(() -> {
702+
try {
703+
datasetService.getById(experiment.datasetId(), workspaceId)
704+
.filter(dataset -> dataset.type() == DatasetType.TEST_SUITE)
705+
.ifPresent(dataset -> analyticsService.trackEvent(userName, "opik_eval_suite_run", Map.of(
706+
"eval_suite_id", dataset.id().toString(),
707+
"experiment_id", experiment.id().toString(),
708+
"project_id", String.valueOf(experiment.projectId()))));
709+
} catch (Exception e) {
710+
log.warn("Failed to track eval_suite_run analytics event for experiment '{}'",
711+
experiment.id(), e);
712+
}
713+
});
693714
}
694715

695716
private Mono<UUID> handleCreateError(Throwable throwable, UUID id) {

apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/DatasetsResourceIntegrationTest.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import com.comet.opik.domain.filter.FilterQueryBuilder;
2020
import com.comet.opik.infrastructure.FeatureFlags;
2121
import com.comet.opik.infrastructure.auth.RequestContext;
22+
import com.comet.opik.infrastructure.bi.AnalyticsService;
2223
import com.comet.opik.infrastructure.db.IdGeneratorImpl;
2324
import com.comet.opik.infrastructure.json.JsonNodeMessageBodyWriter;
2425
import com.comet.opik.podam.PodamFactoryUtils;
@@ -70,6 +71,7 @@ class DatasetsResourceIntegrationTest {
7071
private static final FeatureFlags featureFlags = mock(FeatureFlags.class);
7172
public static final SortingFactoryDatasets sortingFactory = new SortingFactoryDatasets();
7273
private static final CsvDatasetExportService csvExportService = mock(CsvDatasetExportService.class);
74+
private static final AnalyticsService analyticsService = mock(AnalyticsService.class);
7375
private static final ResourceExtension EXT;
7476

7577
static {
@@ -79,7 +81,7 @@ class DatasetsResourceIntegrationTest {
7981
service, itemService, expansionService, versionService, () -> requestContext,
8082
new FiltersFactory(new FilterQueryBuilder()),
8183
new IdGeneratorImpl(), new Streamer(), sortingFactory, csvProcessor,
82-
featureFlags, csvExportService))
84+
featureFlags, csvExportService, analyticsService))
8385
.addProvider(JsonNodeMessageBodyWriter.class)
8486
.addProvider(MultiPartFeature.class)
8587
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())

apps/opik-backend/src/test/java/com/comet/opik/domain/ExperimentServiceTest.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import com.comet.opik.infrastructure.FeatureFlags;
1414
import com.comet.opik.infrastructure.OpikConfiguration;
1515
import com.comet.opik.infrastructure.auth.RequestContext;
16+
import com.comet.opik.infrastructure.bi.AnalyticsService;
1617
import com.comet.opik.podam.PodamFactoryUtils;
1718
import com.fasterxml.jackson.databind.ObjectMapper;
1819
import com.google.common.eventbus.EventBus;
@@ -102,6 +103,9 @@ class ExperimentServiceTest {
102103
@Mock
103104
private ExperimentAggregationPublisher experimentAggregationPublisher;
104105

106+
@Mock
107+
private AnalyticsService analyticsService;
108+
105109
private final ObjectMapper objectMapper = new ObjectMapper();
106110
private final PodamFactory podamFactory = PodamFactoryUtils.newPodamFactory();
107111

@@ -126,7 +130,8 @@ void setUp() {
126130
config,
127131
experimentGroupEnricher,
128132
experimentAggregatesService,
129-
experimentAggregationPublisher);
133+
experimentAggregationPublisher,
134+
analyticsService);
130135
}
131136

132137
@Nested

apps/opik-frontend/src/lib/analytics/tracking.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export const OpikEvent = {
44
ONBOARDING_AGENT_NAME_SUBMITTED: "opik_onboarding_agent_name_submitted",
55
ONBOARDING_FIRST_TRACE_RECEIVED: "opik_onboarding_first_trace_received",
66
ONBOARDING_SKIPPED: "opik_onboarding_skipped",
7+
EVAL_SUITE_UI_CONFIGURED: "opik_eval_suite_ui_configured",
78
} as const;
89

910
type OpikEventValues = (typeof OpikEvent)[keyof typeof OpikEvent];

apps/opik-frontend/src/v2/pages-shared/datasets/AddEditDatasetDialog/useDatasetForm.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { MAX_RUNS_PER_ITEM } from "@/types/test-suites";
1616
import { FeatureToggleKeys } from "@/types/feature-toggles";
1717
import { useIsFeatureEnabled } from "@/contexts/feature-toggles-provider";
1818
import { useClampedIntegerInput } from "@/hooks/useClampedIntegerInput";
19+
import { OpikEvent, trackEvent } from "@/lib/analytics/tracking";
1920

2021
const JSON_MODE_FILE_SIZE_LIMIT_IN_MB = 20;
2122
const JSON_MODE_MAX_ITEMS = 1000;
@@ -245,6 +246,16 @@ const useDatasetForm = ({
245246

246247
const onCreateSuccessHandler = useCallback(
247248
(newDataset: Dataset) => {
249+
if (datasetType === DATASET_TYPE.TEST_SUITE) {
250+
trackEvent(OpikEvent.EVAL_SUITE_UI_CONFIGURED, {
251+
eval_suite_id: newDataset.id,
252+
eval_suite_name: newDataset.name,
253+
has_csv_upload: hasValidCsvFile,
254+
num_assertions: assertions.filter((a) => a.trim()).length,
255+
runs_per_item: runsPerItem,
256+
});
257+
}
258+
248259
const navigateToDataset = () => {
249260
setOpen(false);
250261
onDatasetCreated?.(newDataset);
@@ -275,6 +286,9 @@ const useDatasetForm = ({
275286
onDatasetCreated,
276287
onCreateSuccess,
277288
setOpen,
289+
datasetType,
290+
assertions,
291+
runsPerItem,
278292
],
279293
);
280294

0 commit comments

Comments
 (0)