Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[JN-1600] customer-specific mixpanel export #1443

Merged
merged 5 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions api-admin/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter:3.3.2'
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc:3.4.1'
implementation 'org.springframework.boot:spring-boot-starter-web:3.4.1'
implementation 'org.springframework.boot:spring-boot-starter-cache:3.4.1'
implementation 'org.yaml:snakeyaml:2.3'
implementation 'org.springframework.retry:spring-retry'
implementation 'jakarta.ws.rs:jakarta.ws.rs-api:4.0.0'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
Expand Down Expand Up @@ -43,6 +44,7 @@
@EnableSchedulerLock(defaultLockAtMostFor = "60m")
@EnableConfigurationProperties
@EnableAspectJAutoProxy
@EnableCaching
public class ApiAdminApp {
public static void main(String[] args) {
new SpringApplicationBuilder(ApiAdminApp.class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ private Map<String, String> buildConfigMap() {
Map.of("authToken", maskSecret(airtableConfig.getAuthToken())),
"mixpanel",
Map.of(
"enabled", mixpanelConfig.getEnabled(),
"enabled", mixpanelConfig.getEnabled().toString(),
"token", mixpanelConfig.getToken()));
return internalConfigMap;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import bio.terra.pearl.core.service.portal.PortalService;
import bio.terra.pearl.core.service.portal.exception.PortalConfigMissing;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;

Expand Down Expand Up @@ -64,6 +65,12 @@ public PortalEnvironmentConfig updateConfig(
.find(authContext.getPortalEnvironment().getPortalEnvironmentConfigId())
.orElseThrow(PortalConfigMissing::new);
BeanUtils.copyProperties(newConfig, config, "id", "createdAt");
if (StringUtils.isBlank(config.getParticipantHostname())) {
config.setParticipantHostname(null);
}
if (StringUtils.isBlank(config.getMixpanelToken())) {
config.setMixpanelToken(null);
}
config = portalEnvironmentConfigService.update(config);
return config;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ public void testInternalConfigMap() {
assertThat(addressValidationConfigMap.get("smartyAuthId"), equalTo("sm_id"));
assertThat(addressValidationConfigMap.get("smartyAuthToken"), equalTo("sm..."));

assertThat(testMixpanelConfig.getEnabled(), equalTo("true"));
assertThat(testMixpanelConfig.getEnabled(), equalTo(true));
assertThat(testMixpanelConfig.getToken(), equalTo("mp_token"));
}

Expand Down
1 change: 1 addition & 0 deletions api-participant/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter:3.3.2'
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc:3.4.1'
implementation 'org.springframework.boot:spring-boot-starter-web:3.4.1'
implementation 'org.springframework.boot:spring-boot-starter-cache:3.4.1'
implementation 'org.yaml:snakeyaml:2.3'
implementation 'org.springframework.retry:spring-retry'
implementation 'jakarta.ws.rs:jakarta.ws.rs-api:4.0.0'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.support.JdbcTransactionManager;
import org.springframework.retry.annotation.EnableRetry;
Expand All @@ -31,6 +32,7 @@
@EnableAsync(proxyTargetClass = true)
@EnableTransactionManagement
@EnableConfigurationProperties
@EnableCaching
public class ApiParticipantApp {
public static void main(String[] args) {
new SpringApplicationBuilder(ApiParticipantApp.class)
Expand Down
1 change: 1 addition & 0 deletions core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc:3.4.1'
implementation 'org.springframework.boot:spring-boot-starter-webflux:3.3.2'
implementation 'org.springframework.boot:spring-boot-starter-validation:3.4.0'
implementation 'org.springframework.boot:spring-boot-starter-cache:3.4.1'
implementation 'org.springframework.retry:spring-retry:2.0.10'
implementation 'org.apache.commons:commons-text:1.12.0'
implementation 'org.apache.commons:commons-csv:1.12.0'
Expand Down
2 changes: 2 additions & 0 deletions core/src/main/java/bio/terra/pearl/core/CoreCliApp.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;

/**
* Placeholder application to make spring and liquibase configuration and testing easier.
* Running this application should trigger liquibase migrations
*/
@SpringBootApplication
@Slf4j
@EnableCaching
public class CoreCliApp
implements CommandLineRunner {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package bio.terra.pearl.core.dao.portal;

import bio.terra.pearl.core.dao.BaseMutableJdbiDao;
import bio.terra.pearl.core.model.portal.Portal;
import bio.terra.pearl.core.model.portal.PortalEnvironmentConfig;

import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.jdbi.v3.core.Jdbi;
Expand All @@ -19,12 +22,24 @@ protected Class<PortalEnvironmentConfig> getClazz() {

public Optional<PortalEnvironmentConfig> findByPortalEnvId(UUID portalEnvId) {
return jdbi.withHandle(handle ->
handle.createQuery("select " + prefixedGetQueryColumns("a") + " from " + tableName
+ " a join portal_environment on portal_environment_config_id = a.id"
+ " where portal_environment.id = :portalEnvId")
handle.createQuery("""
select a.* from %s a
join portal_environment on portal_environment_config_id = a.id
where portal_environment.id = :portalEnvId
""".formatted(tableName))
.bind("portalEnvId", portalEnvId)
.mapTo(clazz)
.findOne()
);
}

public List<PortalEnvironmentConfig> findAllWithCustomDomain() {
return jdbi.withHandle(handle ->
handle.createQuery("""
select * from %s where participant_hostname is not null
""".formatted(tableName))
.mapTo(clazz)
.list()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@ public class PortalEnvironmentConfig extends BaseEntity {
@Builder.Default
private String defaultLanguage = "en";
private String primaryStudy; // study shortcode of a study that all initial participants are enrolled in
private String mixpanelToken; // token for mixpanel tracking
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package bio.terra.pearl.core.service.logging;

import bio.terra.pearl.core.model.portal.PortalEnvironmentConfig;
import bio.terra.pearl.core.service.portal.PortalEnvironmentConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.util.Map;

/**
* we don't want to have to go to the database every time an event is logged in our system to get the configs,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great decision to cache this

* and they change very rarely. So we cache them here
*/
@Service
@Slf4j
public class LoggingConfigCache {
private final PortalEnvironmentConfigService portalEnvironmentConfigService;

public static final String CONFIGS_WITH_DOMAIN_CACHE_KEY = "portalEnvironmentConfigsWithDomain";

public LoggingConfigCache(PortalEnvironmentConfigService portalEnvironmentConfigService) {
this.portalEnvironmentConfigService = portalEnvironmentConfigService;
}

@Cacheable(value = CONFIGS_WITH_DOMAIN_CACHE_KEY)
public Map<String, PortalEnvironmentConfig> getConfigsWithDomain() {
return portalEnvironmentConfigService.findAllMappedByCustomDomain();
}

/** since we run multiple instances of the app, we can't rely on cache invalidation on writes,
* so instead we just clear the cache every 10mins */
@CacheEvict(allEntries = true, value = CONFIGS_WITH_DOMAIN_CACHE_KEY)
@Scheduled(fixedDelay = 10 * 60 * 1000, initialDelay = 10 * 60 * 1000)
public void configCacheEvict() {
log.info("Evicting cache for {}", CONFIGS_WITH_DOMAIN_CACHE_KEY);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package bio.terra.pearl.core.service.logging;

import bio.terra.pearl.core.model.portal.PortalEnvironmentConfig;
import com.mixpanel.mixpanelapi.ClientDelivery;
import com.mixpanel.mixpanelapi.MessageBuilder;
import com.mixpanel.mixpanelapi.MixpanelAPI;
Expand All @@ -13,16 +14,21 @@
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;

@Service
@Slf4j
public class MixpanelService {
private final Environment env;

public MixpanelService(Environment env) {
this.env = env;
private static final String MIXPANEL_TOKEN_ENV_VAR = "env.mixpanel.token";
private static final String MIXPANEL_ENABLED_ENV_VAR = "env.mixpanel.enabled";
private final MixpanelConfig mixpanelConfig;
private final LoggingConfigCache loggingConfigCache;

public MixpanelService(MixpanelConfig mixpanelConfig, LoggingConfigCache loggingConfigCache) {
this.mixpanelConfig = mixpanelConfig;
this.loggingConfigCache = loggingConfigCache;
}

private Map<String, String> getRedactionPatterns() {
Expand All @@ -47,59 +53,100 @@ public String filterEventData(String data) {
}

public void logEvent(String data) {
if(!Boolean.parseBoolean(env.getProperty("env.mixpanel.enabled"))) {
if(!mixpanelConfig.enabled) {
return;
}

// Filter all the incoming events in one pass, so we don't have
// to unpack the JSONObject and repack it for each individual event
String filteredData = filterEventData(data);

//Mixpanel sends event data as urlencoded form data, so we need to parse the event data as a JSON array
JSONArray events = new JSONArray(filteredData);

ClientDelivery delivery = new ClientDelivery();
ClientDelivery customDelivery = null;

for (int i = 0; i < events.length(); i++) {
JSONObject mixpanelEvent = buildEvent(events.getJSONObject(i));
JSONObject event = events.getJSONObject(i);
JSONObject mixpanelEvent = buildEvent(event, mixpanelConfig.token);
delivery.addMessage(mixpanelEvent);
String eventDomain = getEventCurrentDomain(event);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not send the portal shortcode when tracking to avoid domain matching?

Copy link
Collaborator Author

@devonbush devonbush Feb 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose we could send portal shortcode and environment (we'd need the environment to not log sandbox events to their mixpanel)


/** check if the event comes from a domain with a dedicated mixpanel token, if so, use that token to send the
* event to that token in addition to the global mixpanel domain */
if (eventDomain != null) {
customDelivery = customDelivery == null ? new ClientDelivery() : customDelivery;
Map<String, PortalEnvironmentConfig> configMap = loggingConfigCache.getConfigsWithDomain();
PortalEnvironmentConfig matchedConfig = configMap.get(eventDomain);
if (matchedConfig != null && matchedConfig.getMixpanelToken() != null) {
JSONObject domainEvent = buildEvent(event, matchedConfig.getMixpanelToken());
customDelivery.addMessage(domainEvent);
}
}
}

deliverEvents(delivery);
if (customDelivery != null) {
deliverEvents(customDelivery);
}
}

protected JSONObject buildEvent(JSONObject event) {
String MIXPANEL_TOKEN_ENV_VAR = "env.mixpanel.token";
MessageBuilder messageBuilder = new MessageBuilder(env.getProperty(MIXPANEL_TOKEN_ENV_VAR));
protected JSONObject buildEvent(JSONObject event, String apiToken) {

MessageBuilder messageBuilder = new MessageBuilder(apiToken);

return messageBuilder.event(
null,
event.getString("event"),
event.getJSONObject("properties")
.put("token", env.getProperty(MIXPANEL_TOKEN_ENV_VAR))
.put("token", apiToken)
);
}

protected void deliverEvents(ClientDelivery delivery) {
MixpanelAPI mixpanel = new MixpanelAPI();

try {
mixpanel.deliver(delivery);
} catch (IOException e) {
log.info("Failed to deliver event to Mixpanel: {}", e.getMessage());
}
}

protected String getEventCurrentDomain(JSONObject event) {
String domain = null;
if (event.has("properties")) {
JSONObject properties = event.getJSONObject("properties");
if (properties.has("$current_url")) {
String url = properties.getString("$current_url");
return getDomainName(url);
}
}
return domain;
}

public static String getDomainName(String url) {
try {
URI uri = new URI(url);
String domain = uri.getHost();
return domain.startsWith("www.") ? domain.substring(4) : domain;
} catch (Exception e) {
return null;
}
}

@Component
@Getter @Setter
public static class MixpanelConfig {
private String token;
private String enabled;
private Boolean enabled;

public MixpanelConfig(Environment environment) {
this.token = environment.getProperty("env.mixpanel.token");
this.enabled = environment.getProperty("env.mixpanel.enabled");
this.enabled = Boolean.parseBoolean(environment.getProperty("env.mixpanel.enabled"));
}
}




}
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,22 @@
import bio.terra.pearl.core.service.CrudService;

import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;

import bio.terra.pearl.core.service.exception.internal.InternalServerException;
import bio.terra.pearl.core.service.publishing.PortalEnvPublishable;
import org.apache.commons.beanutils.PropertyUtils;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class PortalEnvironmentConfigService extends CrudService<PortalEnvironmentConfig, PortalEnvironmentConfigDao> implements PortalEnvPublishable {

public PortalEnvironmentConfigService(PortalEnvironmentConfigDao portalEnvironmentConfigDao) {
super(portalEnvironmentConfigDao);
}
Expand All @@ -27,6 +32,19 @@ public Optional<PortalEnvironmentConfig> findByPortalEnvId(UUID portalEnvId) {
return dao.findByPortalEnvId(portalEnvId);
}

public Map<String, PortalEnvironmentConfig> findAllMappedByCustomDomain() {
return dao.findAllWithCustomDomain().stream()
.collect(Collectors.toMap(PortalEnvironmentConfig::getParticipantHostname, Function.identity()));
}

public PortalEnvironmentConfig create(PortalEnvironmentConfig portalEnvironmentConfig) {
return super.create(portalEnvironmentConfig);
}

public PortalEnvironmentConfig update(PortalEnvironmentConfig portalEnvironmentConfig) {
return super.update(portalEnvironmentConfig);
}

@Override
public void loadForPublishing(PortalEnvironment portalEnv) {
if (portalEnv.getPortalEnvironmentConfigId() != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
databaseChangeLog:
- changeSet:
id: "custom_mixpanel_config"
author: dbush
changes:
- addColumn:
tableName: portal_environment_config
columns:
- column: { name: mixpanel_token, type: text }
Loading