Skip to content

Commit 102e9d0

Browse files
authored
Merge pull request #5 from braintrustdata/ark/unit-test-harness
Ark/unit test harness
2 parents 026c090 + 4434cd1 commit 102e9d0

15 files changed

Lines changed: 422 additions & 295 deletions

‎examples/build.gradle‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,18 @@ task runAnthropicInstrumentation(type: JavaExec) {
9090
suspend = false
9191
}
9292
}
93+
94+
95+
task runPromptFetching(type: JavaExec) {
96+
group = 'Braintrust SDK Examples'
97+
description = 'Run the prompt fetching example'
98+
classpath = sourceSets.main.runtimeClasspath
99+
mainClass = 'dev.braintrust.examples.PromptFetchingExample'
100+
systemProperty 'org.slf4j.simpleLogger.log.dev.braintrust', braintrustLogLevel
101+
debugOptions {
102+
enabled = true
103+
port = 5566
104+
server = true
105+
suspend = false
106+
}
107+
}

‎src/main/java/dev/braintrust/Braintrust.java‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,14 @@
3737
@Slf4j
3838
public class Braintrust {
3939
private static final String SDK_VERSION = SDKMain.loadVersionFromProperties();
40-
private static final AtomicReference<Braintrust> instance = new AtomicReference<>();
40+
private static final AtomicReference<Braintrust> INSTANCE = new AtomicReference<>();
4141

4242
/**
4343
* get or create the global braintrust instance. Most users will want to use this method to
4444
* access the Braintrust SDK.
4545
*/
4646
public static Braintrust get() {
47-
var current = instance.get();
47+
var current = INSTANCE.get();
4848
if (null == current) {
4949
return get(BraintrustConfig.fromEnvironment());
5050
} else {
@@ -54,18 +54,34 @@ public static Braintrust get() {
5454

5555
/** get or create the global braintrust instance from the given config */
5656
public static Braintrust get(BraintrustConfig config) {
57-
var current = instance.get();
57+
var current = INSTANCE.get();
5858
if (null == current) {
59-
var success = instance.compareAndSet(null, of(config));
59+
return set(of(config));
60+
} else {
61+
return current;
62+
}
63+
}
64+
65+
static Braintrust set(Braintrust braintrust) {
66+
var current = INSTANCE.get();
67+
if (null == current) {
68+
var success = INSTANCE.compareAndSet(null, braintrust);
6069
if (success) {
6170
log.info("initialized global Braintrust sdk {}", SDK_VERSION);
71+
} else {
72+
throw new RuntimeException("set must only be called once");
6273
}
63-
return instance.get();
74+
return braintrust;
6475
} else {
6576
return current;
6677
}
6778
}
6879

80+
/** clear global braintrust instance. Only used for testing */
81+
static void resetForTest() {
82+
INSTANCE.set(null);
83+
}
84+
6985
/** Create a new Braintrust instance from the given config */
7086
public static Braintrust of(BraintrustConfig config) {
7187
BraintrustApiClient apiClient = BraintrustApiClient.of(config);
@@ -85,7 +101,7 @@ public static Braintrust of(BraintrustConfig config) {
85101
@Accessors(fluent = true)
86102
private final BraintrustPromptLoader promptLoader;
87103

88-
private Braintrust(
104+
Braintrust(
89105
BraintrustConfig config,
90106
BraintrustApiClient apiClient,
91107
BraintrustPromptLoader promptLoader) {
@@ -94,9 +110,10 @@ private Braintrust(
94110
this.promptLoader = promptLoader;
95111
}
96112

113+
/** the the URI to the configured braintrust org and project */
97114
public URI projectUri() {
98-
// TODO cache?
99-
return config.fetchProjectURI();
115+
return BraintrustUtils.createProjectURI(
116+
config.appUrl(), apiClient().getOrCreateProjectAndOrgInfo(config()));
100117
}
101118

102119
/**
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package dev.braintrust;
2+
3+
import dev.braintrust.api.BraintrustApiClient;
4+
import java.net.URI;
5+
import java.net.URISyntaxException;
6+
7+
public class BraintrustUtils {
8+
/** construct a URI to link to a specific braintrust project within an org */
9+
public static URI createProjectURI(
10+
String appUrl, BraintrustApiClient.OrganizationAndProjectInfo orgAndProject) {
11+
try {
12+
var baseURI = new URI(appUrl);
13+
var path =
14+
"/app/%s/p/%s"
15+
.formatted(
16+
orgAndProject.orgInfo().name(), orgAndProject.project().name());
17+
return new URI(
18+
baseURI.getScheme(),
19+
baseURI.getUserInfo(),
20+
baseURI.getHost(),
21+
baseURI.getPort(),
22+
path,
23+
null,
24+
null);
25+
} catch (URISyntaxException e) {
26+
throw new RuntimeException(e);
27+
}
28+
}
29+
}

‎src/main/java/dev/braintrust/api/BraintrustApiClient.java‎

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ public interface BraintrustApiClient {
4141
/** Get project and org info for the given project ID */
4242
Optional<OrganizationAndProjectInfo> getProjectAndOrgInfo(String projectId);
4343

44+
// TODO: cache project+org info?
45+
/** Get project and org info for the given config. Creating them if necessary */
46+
OrganizationAndProjectInfo getOrCreateProjectAndOrgInfo(BraintrustConfig config);
47+
4448
/** Get a prompt by slug and optional version */
4549
Optional<Prompt> getPrompt(
4650
@Nonnull String projectName, @Nonnull String slug, @Nullable String version);
@@ -145,6 +149,43 @@ public Optional<OrganizationAndProjectInfo> getProjectAndOrgInfo(String projectI
145149
return Optional.of(new OrganizationAndProjectInfo(orgInfo, project));
146150
}
147151

152+
@Override
153+
public OrganizationAndProjectInfo getOrCreateProjectAndOrgInfo(BraintrustConfig config) {
154+
// Get or create project based on config
155+
Project project;
156+
if (config.defaultProjectId().isPresent()) {
157+
var projectId = config.defaultProjectId().get();
158+
project =
159+
getProject(projectId)
160+
.orElseThrow(
161+
() ->
162+
new ApiException(
163+
"Project with ID '"
164+
+ projectId
165+
+ "' not found"));
166+
} else if (config.defaultProjectName().isPresent()) {
167+
var projectName = config.defaultProjectName().get();
168+
project = getOrCreateProject(projectName);
169+
} else {
170+
throw new ApiException(
171+
"Either project ID or project name must be provided in config");
172+
}
173+
174+
// Fetch organization info
175+
OrganizationInfo orgInfo = null;
176+
for (var org : login().orgInfo()) {
177+
if (project.orgId().equalsIgnoreCase(org.id())) {
178+
orgInfo = org;
179+
break;
180+
}
181+
}
182+
if (null == orgInfo) {
183+
throw new ApiException("Unable to find organization for project: " + project.id());
184+
}
185+
186+
return new OrganizationAndProjectInfo(orgInfo, project);
187+
}
188+
148189
@Override
149190
public Optional<Prompt> getPrompt(
150191
@Nonnull String projectName, @Nonnull String slug, @Nullable String version) {
@@ -293,13 +334,14 @@ class InMemoryImpl implements BraintrustApiClient {
293334
private final List<Prompt> prompts = new ArrayList<>();
294335

295336
public InMemoryImpl(OrganizationAndProjectInfo... organizationAndProjectInfos) {
296-
this.organizationAndProjectInfos = List.of(organizationAndProjectInfos);
337+
this.organizationAndProjectInfos =
338+
new ArrayList<>(List.of(organizationAndProjectInfos));
297339
}
298340

299341
public InMemoryImpl(
300342
List<OrganizationAndProjectInfo> organizationAndProjectInfos,
301343
List<Prompt> prompts) {
302-
this.organizationAndProjectInfos = organizationAndProjectInfos;
344+
this.organizationAndProjectInfos = new ArrayList<>(organizationAndProjectInfos);
303345
this.prompts.addAll(prompts);
304346
}
305347

@@ -311,11 +353,24 @@ public Project getOrCreateProject(String projectName) {
311353
return orgAndProject.project();
312354
}
313355
}
314-
throw new RuntimeException(
315-
"Project '"
316-
+ projectName
317-
+ "' not found in test data. Please add it to the InMemoryImpl"
318-
+ " constructor.");
356+
357+
// Create new project if not found
358+
var defaultOrgInfo =
359+
organizationAndProjectInfos.isEmpty()
360+
? new OrganizationInfo("default-org-id", "Default Organization")
361+
: organizationAndProjectInfos.get(0).orgInfo();
362+
363+
var newProject =
364+
new Project(
365+
"project-" + UUID.randomUUID().toString(),
366+
projectName,
367+
defaultOrgInfo.id(),
368+
java.time.Instant.now().toString(),
369+
java.time.Instant.now().toString());
370+
371+
organizationAndProjectInfos.add(
372+
new OrganizationAndProjectInfo(defaultOrgInfo, newProject));
373+
return newProject;
319374
}
320375

321376
@Override
@@ -357,6 +412,39 @@ public Optional<OrganizationAndProjectInfo> getProjectAndOrgInfo(String projectI
357412
.findFirst();
358413
}
359414

415+
@Override
416+
public OrganizationAndProjectInfo getOrCreateProjectAndOrgInfo(BraintrustConfig config) {
417+
// Get or create project based on config
418+
Project project;
419+
if (config.defaultProjectId().isPresent()) {
420+
var projectId = config.defaultProjectId().get();
421+
project =
422+
getProject(projectId)
423+
.orElseThrow(
424+
() ->
425+
new ApiException(
426+
"Project with ID '"
427+
+ projectId
428+
+ "' not found"));
429+
} else if (config.defaultProjectName().isPresent()) {
430+
var projectName = config.defaultProjectName().get();
431+
project = getOrCreateProject(projectName);
432+
} else {
433+
throw new ApiException(
434+
"Either project ID or project name must be provided in config");
435+
}
436+
437+
// Find the organization info for this project
438+
return organizationAndProjectInfos.stream()
439+
.filter(info -> info.project().id().equals(project.id()))
440+
.findFirst()
441+
.orElseThrow(
442+
() ->
443+
new ApiException(
444+
"Unable to find organization for project: "
445+
+ project.id()));
446+
}
447+
360448
@Override
361449
public Optional<Prompt> getPrompt(
362450
@Nonnull String projectName, @Nonnull String slug, @Nullable String version) {

‎src/main/java/dev/braintrust/config/BraintrustConfig.java‎

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
package dev.braintrust.config;
22

3+
import dev.braintrust.Braintrust;
4+
import dev.braintrust.BraintrustUtils;
35
import dev.braintrust.api.BraintrustApiClient;
46
import java.net.URI;
5-
import java.net.URISyntaxException;
67
import java.time.Duration;
78
import java.util.HashMap;
89
import java.util.Map;
@@ -87,29 +88,11 @@ public Optional<String> getBraintrustParentValue() {
8788
}
8889
}
8990

90-
/** fetch all project info and IDs from the braintrust api */
91+
/** Deprecated. Please use {@link Braintrust#projectUri()} instead */
92+
@Deprecated
9193
public URI fetchProjectURI() {
92-
return fetchProjectURI(BraintrustApiClient.of(this));
93-
}
94-
95-
URI fetchProjectURI(BraintrustApiClient client) {
96-
try {
97-
var orgAndProject = client.getProjectAndOrgInfo().orElseThrow();
98-
var baseURI = new URI(appUrl());
99-
var path =
100-
"/app/%s/p/%s"
101-
.formatted(
102-
orgAndProject.orgInfo().name(), orgAndProject.project().name());
103-
return new URI(
104-
baseURI.getScheme(),
105-
baseURI.getUserInfo(),
106-
baseURI.getHost(),
107-
baseURI.getPort(),
108-
path,
109-
null,
110-
null);
111-
} catch (URISyntaxException e) {
112-
throw new RuntimeException(e);
113-
}
94+
var client = BraintrustApiClient.of(this);
95+
var orgAndProject = client.getProjectAndOrgInfo().orElseThrow();
96+
return BraintrustUtils.createProjectURI(appUrl(), orgAndProject);
11497
}
11598
}

‎src/main/java/dev/braintrust/eval/Eval.java‎

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22

33
import com.fasterxml.jackson.core.JsonProcessingException;
44
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import dev.braintrust.BraintrustUtils;
56
import dev.braintrust.api.BraintrustApiClient;
67
import dev.braintrust.config.BraintrustConfig;
78
import dev.braintrust.trace.BraintrustContext;
89
import dev.braintrust.trace.BraintrustTracing;
910
import io.opentelemetry.api.common.AttributeKey;
1011
import io.opentelemetry.api.trace.SpanKind;
1112
import io.opentelemetry.api.trace.Tracer;
12-
import java.net.URI;
1313
import java.util.*;
1414
import java.util.function.Function;
1515
import javax.annotation.Nonnull;
@@ -158,19 +158,12 @@ public class Result {
158158

159159
@SneakyThrows
160160
private Result() {
161-
var baseURI = new URI(config.appUrl());
162161
this.experimentUrl =
163-
new URI(
164-
baseURI.getScheme(),
165-
baseURI.getHost(),
166-
"/app/"
167-
+ orgAndProject.orgInfo().name()
168-
+ "/p/"
169-
+ orgAndProject.project().name()
170-
+ "/experiments/"
171-
+ experimentName,
172-
null)
173-
.toASCIIString();
162+
"%s/experiments/%s"
163+
.formatted(
164+
BraintrustUtils.createProjectURI(config.appUrl(), orgAndProject)
165+
.toASCIIString(),
166+
experimentName);
174167
}
175168

176169
public String createReportString() {

‎src/main/java/dev/braintrust/trace/BraintrustSpanExporter.java‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import java.util.List;
1010
import java.util.Map;
1111
import java.util.concurrent.ConcurrentHashMap;
12-
import java.util.concurrent.CopyOnWriteArrayList;
1312
import java.util.stream.Collectors;
1413
import lombok.extern.slf4j.Slf4j;
1514

@@ -19,9 +18,6 @@
1918
*/
2019
@Slf4j
2120
class BraintrustSpanExporter implements SpanExporter {
22-
/** Only used in unit tests. */
23-
static final Map<String, List<SpanData>> SPANS_EXPORTED = new ConcurrentHashMap<>();
24-
2521
private final BraintrustConfig config;
2622
private final String tracesEndpoint;
2723
private final Map<String, OtlpHttpSpanExporter> exporterCache = new ConcurrentHashMap<>();
@@ -90,8 +86,8 @@ private CompletableResultCode exportWithParent(String parent, List<SpanData> spa
9086
});
9187

9288
if (config.exportSpansInMemoryForUnitTest()) {
93-
SPANS_EXPORTED.putIfAbsent(parent, new CopyOnWriteArrayList<>());
94-
SPANS_EXPORTED.get(parent).addAll(spans);
89+
// unit test harness hooks up an in-memory exporter so we don't need to do anything
90+
// here
9591
return CompletableResultCode.ofSuccess();
9692
} else {
9793
var result = exporter.export(spans);

0 commit comments

Comments
 (0)