diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..3d294d96 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +.gitignore + +.idea +.DS_Store + +build/ +examples/ \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..ccaa3718 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior or code snippets that produce the issue. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Environment (please complete the following information):** + - OS: [e.g. Ubuntu 20.04] + - Version [e.g. Java 12] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..11fc491e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/admin.yml b/.github/workflows/admin.yml new file mode 100644 index 00000000..6cbf6559 --- /dev/null +++ b/.github/workflows/admin.yml @@ -0,0 +1,36 @@ +name: Admin + +on: + create: + pull_request: + types: + - opened + branches: + - 'release/**' + push: + branches: + - "release/**" + +jobs: + update-labels: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Update issue status labels + uses: snowplow-incubator/labels-helper-action@v1 + env: + ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + link-pr-issue: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Match the issue to the PR + uses: snowplow-incubator/pull-request-helper-action@v1 + env: + ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..3a708a09 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,62 @@ + +name: Build + +on: [ push ] + +jobs: + prepare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: gradle/wrapper-validation-action@v1 + + build: + needs: prepare + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # build and test against LTS releases + java: [ 8, 11, 17 ] + + steps: + - uses: actions/checkout@v2 + + - name: Set up JDK + uses: actions/setup-java@v1 + with: + java-version: ${{ matrix.java }} + + - name: Cache Gradle packages + uses: actions/cache@v2 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle Wrapper + run: ./gradlew build -x test + + - name: Test with Gradle Wrapper + run: ./gradlew test + + - name: Build simple-console example + run: | + ./gradlew publishToMavenLocal + cd examples/simple-console + ./gradlew build + + - name: Upload report if failed + if: ${{ failure() }} + uses: actions/upload-artifact@v2 + with: + name: Reports-JDK_${{ matrix.java }} + path: build/reports + + - uses: actions/upload-artifact@v1 + with: + name: Package-JDK_${{ matrix.java }} + path: build/libs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..be7bd7a8 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,53 @@ +name: Deploy + +on: + push: + tags: + - '*.*.*' + +jobs: + deploy: + + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Validate Gradle wrapper + uses: gradle/wrapper-validation-action@v1 + + - name: Set up JDK + uses: actions/setup-java@v1 + with: + java-version: 8 + + - name: Build + run: ./gradlew build + + - name: Get tag and tracker version information + id: version + run: | + echo ::set-output name=TAG_VERSION::${GITHUB_REF#refs/*/} + echo "##[set-output name=TRACKER_VERSION;]$(./gradlew -q printVersion)" + + - name: Fail if version mismatch + if: ${{ steps.version.outputs.TAG_VERSION != steps.version.outputs.TRACKER_VERSION }} + run: | + echo "Tag version (${{ steps.version.outputs.TAG_VERSION }}) doesn't match version in project (${{ steps.version.outputs.TRACKER_VERSION }})" + exit 1 + + - name: Publish to Maven Central + run: ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository + env: + SONA_USER: ${{ secrets.SONA_USER }} + SONA_PASS: ${{ secrets.SONA_PASS }} + ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.SONA_PGP_PASSPHRASE }} + ORG_GRADLE_PROJECT_signingKey: ${{ secrets.SONA_PGP_SECRET }} + + - name: Release on GitHub + uses: softprops/action-gh-release@v1 + with: + name: Version ${{ steps.version.outputs.TRACKER_VERSION }} + prerelease: ${{ contains(steps.version.outputs.TRACKER_VERSION, '-') }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 00000000..c697bad1 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,30 @@ +name: Documentation + +on: + push: + branches: [ master ] + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Validate Gradle wrapper + uses: gradle/wrapper-validation-action@v1 + + - name: Set up JDK + uses: actions/setup-java@v1 + with: + java-version: 8 + + - name: Build + run: ./gradlew build + + - name: Deploy to GitHub Pages + if: success() + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./build/docs/javadoc diff --git a/.github/workflows/snyk.yml b/.github/workflows/snyk.yml new file mode 100644 index 00000000..a26496d3 --- /dev/null +++ b/.github/workflows/snyk.yml @@ -0,0 +1,21 @@ + +name: Snyk + +on: + push: + branches: [ master ] + +jobs: + security: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Run Snyk to check for vulnerabilities + uses: snyk/actions/gradle@master + with: + command: monitor + args: --project-name=snowplow-java-tracker + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} diff --git a/.gitignore b/.gitignore index c3017731..ee9599a2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ *.war *.ear +# Don't ignore gradle wrapper +!gradle-wrapper.jar + # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* @@ -52,4 +55,11 @@ com_crashlytics_export_strings.xml local.properties # Ignoring Version.java since its auto-generated -#Version.java +Version.java + +#macOS +.DS_Store + +# Eclipse +.project +.settings/ \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ca6ca2da..00000000 --- a/.travis.yml +++ /dev/null @@ -1,5 +0,0 @@ -language: java - -jdk: - - openjdk7 - - oraclejdk7 diff --git a/CHANGELOG b/CHANGELOG index b9c57898..c750412a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,224 @@ +Java 2.1.0 (2024-02-14) +----------------------- +Add support for serializing DateTime in self-describing data (#378) (thanks to @stephen-murby for the contribution!) +Add equality functions for SelfDescribing and SelfDescribingJson so that they can be compared in unit tests (#380) (thanks to @stephen-murby for the contribution!) + +Java 2.0.0 (2024-01-12) +----------------------- +Add builder methods Subject to allow method chaining (#303) +Add okhttp adapter with cookie jar and remove cookie jar from network configuration (#361) +Remove deprecated APIs (#373) +Update to Apache Http Client to v5 (#364) +Upgrade okhttp dependency to version 4.12 (#365) +Bump slf4j-simple and slf4j-api to 2.0.11 +Bump junit-jupiter-api to 5.10.1 +Bump jackson-databind to 2.16.1 +Bump commons-net to 3.10 +Bump commons-codec to 1.16 +Update copyright headers in source files (#375) + +Java 1.0.1 (2023-11-06) +----------------------- +Fix Issue with OkHttpClientAdapter (#366) (thanks to @eusorov for the contribution!) + +Java 1.0.0 (2022-09-06) +----------------------- +Add close() to Emitter interface and Tracker (#357) +Deprecate Builder classes (#355) +Reduce the default maximum event buffer capacity (#352) +Add admin workflow for automatic issue labelling (#346) +Remove SimpleEmitter (#341) +Add a Snowplow interface with the ability to initialize and manage multiple trackers (#340) +Restore Emitter callbacks for success and failure (#339) +Add a maximum wait time and jitter to event sending retry (#338) +Set default HTTP status codes not to retry on (#337) +Add support for storing cookies in OkHttpClientAdapter (#336) +Remove Guava dependency (#320) +Standardise API for Tracker and Subject Builders (#302) +Rename Unstructured events to SelfDescribing (#296) + +Java 0.12.2 (2022-06-17) +----------------------- +Bump jackson-databind to 2.13.3 (#333) + +Java 0.12.1 (2022-05-11) +----------------------- +Bump junit to 4.13.2 (#330) +Bump mockwebserver to 4.9.3 (#329) +Bump junit-jupiter-api to 5.8.2 (#328) +Bump guava to 31.1-jre (#327) +Bump jackson-databind to 2.13.2.2 (#326) +Bump slf4j-simple to 1.7.36 (#325) +Bump slf4j-api to 1.7.36 (#324) +Bump okhttp to 4.9.3 (#323) +Bump httpasyncclient to 4.1.5 (#322) +Bump commons-codec to 1.15 (#321) + +Java 0.12.0 (2022-03-24) +----------------------- +Choose HTTP response codes not to retry (#316) +Add Javadoc generation (#137) +Deprecate SimpleEmitter (#309) +Update junit and jackson-databind (#294) +Update copyright notices to 2022 (#312) +Return eventId from Tracker.track() (#304) +Add retry to in-memory storage system (#156) +Rename bufferSize to batchSize (#306) +Add benchmarking tests (#300) +Update simple-console example (#295) +Provide method for stopping Tracker executorService (#297) +Refactor TrackerEvents for event payload creation (#291) +Extract event storage from Emitter (#290) +Attribute community contributions in changelog (#289) + +Java 0.11.0 (2021-12-14) +----------------------- +Remove logging of user supplied values (#286) +Update Deploy action to remove Bintray (#283) +Set Emitter's threads name for easier debugging (#280) (Thanks @AcidFlow!) +Update all copyright notices (#279) +Allow Emitter to use a custom ExecutorService (#278) (Thanks @AcidFlow!) +Specify the key for 'null or empty value detected' payload log (#277) (Thanks @b-ryan!) +Remove Mockito and Wiremock dependencies (#275) +Update dependencies guava, wiremock, and httpclient (#269) +Manually set the session_id (#265) +Update gradle GH Action to include Java 17 (#273) +Remove HttpHeaders dependency in OkHttpClientAdapter (#266) +Replace Vagrant with Docker (#267) + + +Java 0.10.1 (2020-06-11) +----------------------- +Publish Gradle module file with bintrayUpload (#255) +Update snyk integration to include project name in GitHub action (#256) + +Java 0.10.0 (2020-06-10) +----------------------- +Add snyk monitor to Github Actions (#253) +Update copyright years (#227) +Update README to point to docs.snowplowanalytics.com (#251) +Switch to GitHub Actions for build and release (#231) +Switch junit to native Gradle support (#240) +Bump org.apache.httpcomponents:httpasyncclient to 4.1.4 (#249) +Bump org.apache.httpcomponents:httpclient to 4.5.12 (#248) +Bump mockito-core to 3.3.3 (#247) +Bump slf4j-api to 1.7.30 (#246) +Bump commons-net to 3.6 (#245) +Bump commons-codec to 1.14 (#241) +Bump mockwebserver to 4.7.2 (#239) +Bump guava to 29.0 (#238) +Bump wiremock to 2.26.3 (#237) +Bump jackson-databind to 2.11.0 (#235) +Upgrade to Gradle 6 (#236) +Add default HttpClientAdapter so users do not have to create one (#165) +Support for creating TrackerPayload asynchronously (#222) +Add POM information to Maven Publishing section in build.gradle (#234) +Remove use of deprecated OkHttp methods (#228) +Switch build.gradle to use https://repo.spring.io/plugins-release (#223) + +Java 0.9.0 (2019-12-24) +----------------------- +Bump OkHttp to OkHttp3 version 4 (#175) +Add STM to outbound events (#169) +Add support for attaching true timestamp to events (#178) +Update all non-static Loggers to static (#213) +Fix events sent by example simple-console (#221) +Alter logging for invalid keys only when adding to TrackerPayload (#186) +Fix Peru version so vagrant up succeeds (#216) +Fix Javadoc generation warnings (#219) + +Java 0.8.4 (2019-01-09) +----------------------- +Add deployment to build process (#183) +Add sonatype credentials to .travis.yml (#209) +Add Bintray credentials to .travis.yml (#208) + +Java 0.8.3 (2019-01-02) +----------------------- +Change some info statements to debug (#202) +Change `slf4j-simple` to a test runtime dependency (#188) +Close ResponseBody (#195) +Use UTF-8 encoding in events (#181) +Make tracker exit cleanly (#187) +Add simple-console sample project (#191) +Fix README.md formatting (#190) +Remove JDK7 and add OpenJDK8 in Travis build matrix (#205) +Change sourceCompatibility and targetCompatibility to 1.8 (#204) +Update Gradle to 5.0 (#203) +Add Java 11 to Travis build matrix (#207) + +Java 0.8.2 (2016-02-28) +----------------------- +Fixed GET requests not being properly encoded (#174) +Upgraded commons-codec version (#172) + +Java 0.8.1 (2015-10-01) +----------------------- +Timing event field is incorrectly converted to a String (#166) + +Java 0.8.0 (2015-09-14) +----------------------- +Moved Version.java into gitignored sub-package (#135) +Added builder pattern for Tracker (#148) +Decoupled Subject from Tracker (#144) +Added builder pattern for Emitter (#149) +Added builder pattern for Subject (#150) +Added builder pattern for all Events (#147) +Added Timing Event (#154) +Made an abstract event class and add an event interface (#163) +Fixed eid and dtm being incorrectly added to screen_view and timing context (#161) +Added ability to set event ID when tracking (#133) +Added SelfDescribingJson class (#151) +Ensured only String values are added to the TrackerPayload (#127) +Made event sending for GET & POST Asynchronous (#157) +Made http client configurable, thanks @dstendardi! (#146) +Added builder pattern for ClientAdapters (#158) +Made AbstractEmitter abstract again with builder patterm (#159) +Expanded Emitter interface to include getters and setters for all parameters (#162) +Fixed NPE if Collector URI is invalid (#131) +Added setNetworkUserId to Subject (#125) +Added setDomainUserId to Subject (#124) +Added setIpAddress to Subject (#88) +Added setUseragent to Subject (#87) +Updated contexts schema to 1-0-1 (#100) +Updated payload_data to 1-0-3 (#89) +Expanded Test Suite to cover the library properly (#160) +Bumped Vagrant Java Version to 1.7 (#153) +Fixed Vagrant Peru.yaml file (#152) +Fixed badge link (#136) + +Java 0.7.0 (2015-01-24) +----------------------- +Consolidated Tracker Core module into Java Tracker, thanks @dstendardi! (#116) +Removed war packaging from Gradle build, thanks @dstendardi! (#117) +Hardcoded artifactId to prevent vagrant folder being used (#138) +Updated Emitter and Tracker tests to use WireMock, thanks @jonalmeida! (#40) +Added Java 6 and 8 to Travis build matrix (#132) +Removed deprecated add() methods from SchemaPayload (#72) +Relocated add() methods from Payload into TrackerPayload (#126) +Added Guava back as a dependency (#123) +Replaced homebrew Base64 implementation with Apache Commons Codec (#122) +Added Release button to README (#129) +Added License button to README (#128) + +Java 0.6.0 (2014-12-27) +----------------------- +Bumped Core version to 0.2.0 (#108) +Added Quickstart section to README (#111) +Added dedicated Vagrant setup (#106) +Added warning that Version.java is auto-generated (#112) + +Core 0.2.0 (2014-12-27) +----------------------- +Fixed incorrect schema for trackScreenView, thanks @lixiaoyi! (#104) +Removed Guava as a dependency, thanks @hamidp! (#105) +Made eventData a SchemaPayload in trackUnstructuredEvent, thanks @jonalmeida! (#76) +Added @Deprecated on the unused SchemaPayload methods, thanks @jonalmeida! (#85) +Moved platform setting out of Subject into Tracker (#103) +Made setSubject method on Tracker public (#109) +Tidied up approach to schema constants in Tracker (#110) +Added warning that Version.java is auto-generated (#113) + Java 0.5.2 (2014-11-25) ----------------------- Bumped Core version to 0.1.4 (#101) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..da02c7c4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,370 @@ +# Snowplow Java Tracker - Architecture & Development Guide + +## Project Overview + +The Snowplow Java Tracker is a library for tracking analytics events and sending them to Snowplow collectors. It provides a robust, configurable event tracking system for Java applications (JDK 8+) with support for batch processing, retry logic, and multiple HTTP client implementations. + +**Key Technologies:** +- Java 8+ (minimum requirement) +- Gradle 6.5.0 build system +- Jackson for JSON processing +- OkHttp/Apache HTTP clients (optional features) +- JUnit 4/5 for testing +- SLF4J for logging + +## Development Commands + +```bash +# Build and run tests +./gradlew build + +# Run tests only +./gradlew test + +# Publish to local Maven repository +./gradlew publishToMavenLocal + +# Generate version info +./gradlew generateSources + +# Clean build artifacts +./gradlew clean + +# Run example application +cd examples/simple-console +./gradlew jar +java -jar ./build/libs/simple-console-all-0.0.1.jar "http://collector-url" +``` + +## Architecture + +### Core Components + +1. **Tracker**: Central component that processes and sends events +2. **Emitter**: Handles HTTP communication and batch processing +3. **Events**: Type-safe event models (PageView, Structured, SelfDescribing, etc.) +4. **Subject**: User/device information attached to events +5. **Payload**: Event data structures and serialization +6. **Configuration**: Builder-pattern configuration objects + +### Layer Organization + +``` +com.snowplowanalytics.snowplow.tracker/ +├── configuration/ # Configuration builders +├── emitter/ # Event transmission layer +├── events/ # Event type definitions +├── payload/ # Data structures & serialization +├── http/ # HTTP client adapters +└── constants/ # Constants and parameters +``` + +## Core Architectural Principles + +### 1. Configuration-First Design +All components use configuration objects with fluent builders: +```java +// ✅ Use configuration objects +TrackerConfiguration config = new TrackerConfiguration(namespace, appId) + .platform(DevicePlatform.ServerSideApp) + .base64Encoded(true); + +// ❌ Don't use complex constructors +new Tracker(namespace, appId, platform, base64, emitter, subject); +``` + +### 2. Builder Pattern for Events +All events use the builder pattern for construction: +```java +// ✅ Use builders for events +PageView event = PageView.builder() + .pageUrl("https://example.com") + .customContext(contexts) + .build(); + +// ❌ Don't use constructors or setters +PageView event = new PageView(); +event.setPageUrl("https://example.com"); +``` + +### 3. Immutable Event Data +Events are immutable after creation: +```java +// ✅ Create complete events +Structured event = Structured.builder() + .category("category") + .action("action") + .build(); + +// ❌ Don't modify after creation +event.setCategory("new-category"); // No such method +``` + +### 4. Nullable Pattern with Validation +Required fields are validated, optional fields can be null: +```java +// ✅ Validate required fields +Objects.requireNonNull(namespace); +if (namespace.isEmpty()) { + throw new IllegalArgumentException("namespace cannot be empty"); +} + +// ✅ Optional fields can be null +private Subject subject; // Can be null +``` + +## Critical Import Patterns + +### Standard Package Organization +```java +// ✅ Correct import order +// 1. Java standard library +import java.util.*; +import java.io.Closeable; + +// 2. Third-party libraries +import org.slf4j.Logger; +import com.fasterxml.jackson.databind.ObjectMapper; + +// 3. Snowplow tracker packages +import com.snowplowanalytics.snowplow.tracker.*; +import com.snowplowanalytics.snowplow.tracker.events.*; +``` + +## Essential Library Patterns + +### 1. Tracker Creation Pattern +```java +// ✅ Use Snowplow factory with configurations +Tracker tracker = Snowplow.createTracker( + trackerConfig, + networkConfig, + emitterConfig, + subjectConfig +); + +// ❌ Don't create tracker directly +Tracker tracker = new Tracker(config, emitter); +``` + +### 2. Event Tracking Pattern +```java +// ✅ Track returns event IDs +List eventIds = tracker.track(event); + +// ✅ Handle batch events (EcommerceTransaction) +List ids = tracker.track(transaction); // May return multiple IDs +``` + +### 3. SelfDescribingJson Pattern +```java +// ✅ Use schema + data constructor +SelfDescribingJson context = new SelfDescribingJson( + "iglu:com.example/context/jsonschema/1-0-0", + Collections.singletonMap("key", "value") +); + +// ❌ Don't use TrackerPayload as data +new SelfDescribingJson(schema, new TrackerPayload()); // Contains unwanted eid/dtm +``` + +### 4. HTTP Client Adapter Pattern +```java +// ✅ Let configuration choose adapter +BatchEmitter emitter = new BatchEmitter(networkConfig, emitterConfig); + +// ✅ Or provide custom adapter +HttpClientAdapter adapter = new OkHttpClientAdapter(url); +networkConfig.httpClientAdapter(adapter); +``` + +## Model Organization Pattern + +### Event Hierarchy +``` +Event (interface) +└── AbstractEvent (base class) + ├── PageView + ├── Structured + ├── SelfDescribing + ├── ScreenView + ├── Timing + ├── EcommerceTransaction + └── EcommerceTransactionItem +``` + +### Payload Types +``` +Payload (interface) +├── TrackerPayload (main event payload) +├── SelfDescribingJson (schema-based data) +└── BatchPayload (POST request wrapper) +``` + +## Common Pitfalls & Solutions + +### 1. TrackerPayload in SelfDescribingJson +```java +// ❌ Wrong: TrackerPayload adds unwanted eid/dtm +SelfDescribingJson data = new SelfDescribingJson( + schema, + new TrackerPayload() +); + +// ✅ Correct: Use Map or Object +SelfDescribingJson data = new SelfDescribingJson( + schema, + new HashMap() +); +``` + +### 2. Synchronous Event Sending +```java +// ❌ Wrong: Expecting immediate send +tracker.track(event); +// Event not sent yet! + +// ✅ Correct: Events are batched +tracker.track(event); +tracker.getEmitter().flushBuffer(); // Force send +tracker.close(); // Or close to flush +``` + +### 3. Missing Required Configuration +```java +// ❌ Wrong: Missing collector URL +NetworkConfiguration network = new NetworkConfiguration(); + +// ✅ Correct: Provide URL or adapter +NetworkConfiguration network = new NetworkConfiguration("https://collector.example.com"); +``` + +### 4. Thread Safety +```java +// ❌ Wrong: Sharing Subject across threads +Subject shared = new Subject(); +// Multiple threads modifying shared + +// ✅ Correct: Event-specific subjects +PageView.builder() + .subject(new Subject()) // Thread-local + .build(); +``` + +## File Structure Template + +``` +snowplow-java-tracker/ +├── build.gradle # Main build configuration +├── src/ +│ ├── main/java/com/snowplowanalytics/snowplow/tracker/ +│ │ ├── Tracker.java # Core tracker +│ │ ├── Snowplow.java # Factory & registry +│ │ ├── Subject.java # User/device info +│ │ ├── configuration/ # Config objects +│ │ ├── emitter/ # Event transmission +│ │ ├── events/ # Event types +│ │ ├── payload/ # Data structures +│ │ └── http/ # HTTP adapters +│ └── test/java/ # Unit tests +└── examples/ + └── simple-console/ # Usage example +``` + +## Testing Patterns + +### 1. Mock Emitter Pattern +```java +// ✅ Use MockEmitter for testing +class MockEmitter implements Emitter { + public List eventList = new ArrayList<>(); + + @Override + public boolean add(TrackerPayload payload) { + eventList.add(payload); + return true; + } +} +``` + +### 2. MockWebServer for HTTP Tests +```java +// ✅ Use OkHttp MockWebServer +MockWebServer server = new MockWebServer(); +server.enqueue(new MockResponse().setResponseCode(200)); +String url = server.url("/").toString(); +``` + +### 3. Test Event Creation +```java +// ✅ Test with all optional fields +PageView event = PageView.builder() + .pageUrl("https://example.com") + .customContext(contexts) + .trueTimestamp(timestamp) + .subject(subject) + .build(); +``` + +## Quick Reference + +### Event Types Checklist +- [ ] **PageView**: Web page views +- [ ] **Structured**: Category/action events +- [ ] **SelfDescribing**: Custom schema-based events +- [ ] **ScreenView**: Mobile screen views +- [ ] **Timing**: Performance timing +- [ ] **EcommerceTransaction**: Purchase events (deprecated) + +### Configuration Components +- [ ] **TrackerConfiguration**: namespace, appId, platform +- [ ] **NetworkConfiguration**: collector URL, HTTP client +- [ ] **EmitterConfiguration**: batch size, thread count, callbacks +- [ ] **SubjectConfiguration**: user ID, session, device info + +### Snowplow Concepts +- **Collector**: Endpoint receiving events +- **Schema**: Iglu schema for self-describing data +- **Context**: Additional data attached to events +- **Subject**: User/device information +- **Namespace**: Unique tracker identifier + +## Contributing to CLAUDE.md + +When adding or updating content in this document, please follow these guidelines: + +### File Size Limit +- **CLAUDE.md must not exceed 40KB** (currently ~19KB) +- Check file size after updates: `wc -c CLAUDE.md` +- Remove outdated content if approaching the limit + +### Code Examples +- Keep all code examples **4 lines or fewer** +- Focus on the essential pattern, not complete implementations +- Use `// ❌` and `// ✅` to clearly show wrong vs right approaches + +### Content Organization +- Add new patterns to existing sections when possible +- Create new sections sparingly to maintain structure +- Update the architectural principles section for major changes +- Ensure examples follow current codebase conventions + +### Quality Standards +- Test any new patterns in actual code before documenting +- Verify imports and syntax are correct for the codebase +- Keep language concise and actionable +- Focus on "what" and "how", minimize "why" explanations + +### Multiple CLAUDE.md Files +- **Directory-specific CLAUDE.md files** can be created for specialized modules +- Follow the same structure and guidelines as this root CLAUDE.md +- Keep them focused on directory-specific patterns and conventions +- Maximum 20KB per directory-specific CLAUDE.md file + +### Instructions for LLMs +When editing files in this repository, **always check for CLAUDE.md guidance**: + +1. **Look for CLAUDE.md in the same directory** as the file being edited +2. **If not found, check parent directories** recursively up to project root +3. **Follow the patterns and conventions** described in the applicable CLAUDE.md +4. **Prioritize directory-specific guidance** over root-level guidance when conflicts exist \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..1decb69b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,80 @@ +# Contributing + +The Snowplow Java Tracker is maintained by the Engineering team at Snowplow Analytics. We welcome suggestions for improvements and bug fixes to all Snowplow Trackers. + +We are extremely grateful for all contributions we receive, whether that is reporting an issue or a change to the code which can be made in the form of a pull request. + +For support requests, please use our community support Discourse forum: https://discourse.snowplowanalytics.com/. + +## Setting up an Environment + +Instructions on how to build and run tests are available in the [README.md](README.md). The README will also list any requirements that you will need to install first before being able to build and run the tests. + +You should ensure you are comfortable building and testing the existing release before adding new functionality or fixing issues. + +## Issues + +### Creating an issue + +The project contains an issue template which should help guiding you through the process. However, please keep in mind that support requests should go to our Discourse forum: https://discourse.snowplowanalytics.com/ and not GitHub issues. + +It's also a good idea to log an issue before starting to work on a pull request to discuss it with the maintainers. A pull request is just one solution to a problem and it is often a good idea to talk about the problem with the maintainers first. + +### Working on an issue + +If you see an issue you would like to work on, please let us know in the issue! That will help us in terms of scheduling and +not doubling the amount of work. + +If you don't know where to start contributing, you can look at +[the issues labeled `good first issue`](https://github.com/snowplow/snowplow-java-tracker/labels/good%20first%20issue). + +## Pull requests + +These are a few guidelines to keep in mind when opening pull requests. + +### Guidelines + +Please supply a good PR description. These are very helpful and help the maintainers to understand _why_ the change has been made, not just _what_ changes have been made. + +Please try and keep your PR to a single feature of fix. This might mean breaking up a feature into multiple PRs but this makes it easier for the maintainers to review and also reduces the risk in each change. + +Please review your own PR as you would do it you were a reviewer first. This is a great way to spot any mistakes you made when writing the change. Additionally, ensure your code compiles and all tests pass. + +### Commit hygiene + +We keep a strict 1-to-1 correspondance between commits and issues, as such our commit messages are formatted in the following +fashion: + +`Issue Description (closes #1234)` + +for example: + +`Fix Issue with Tracker (closes #1234)` + +### Writing tests + +Whenever necessary, it's good practice to add the corresponding tests to whichever feature you are working on. +Any non-trivial PR must have tests and will not be accepted without them. + +### Feedback cycle + +Reviews should happen fairly quickly during weekdays. +If you feel your pull request has been forgotten, please ping one or more maintainers in the pull request. + +### Getting your pull request merged + +If your pull request is fairly chunky, there might be a non-trivial delay between the moment the pull request is approved and the moment it gets merged. This is because your pull request will have been scheduled for a specific milestone which might or might not be actively worked on by a maintainer at the moment. + +### Contributor license agreement + +We require outside contributors to sign a Contributor license agreement (or CLA) before we can merge their pull requests. +You can find more information on the topic in [the dedicated wiki page](https://github.com/snowplow/snowplow/wiki/CLA). +The @snowplowcla bot will guide you through the process. + +## Getting in touch + +### Community support requests + +Please do not log an issue if you are asking for support, all of our community support requests go through our Discourse forum: https://discourse.snowplowanalytics.com/. + +Posting your problem there ensures more people will see it and you should get support faster than creating a new issue on GitHub. Please do create a new issue on GitHub if you think you've found a bug though! \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..cca23c3c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:11 +COPY . /java-tracker +WORKDIR /java-tracker +RUN ./gradlew build diff --git a/LICENSE-2.0.txt b/LICENSE similarity index 99% rename from LICENSE-2.0.txt rename to LICENSE index 7a4a3ea2..0e1f4fe1 100644 --- a/LICENSE-2.0.txt +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2014-present Snowplow Analytics Ltd. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -199,4 +199,4 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file + limitations under the License. diff --git a/README.md b/README.md index 05f6d37b..839e8fd3 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,60 @@ # Java Analytics for Snowplow -[ ![Build Status] [travis-image] ] [travis] +[![maintained]][tracker-classification] [![Build][github-image]][github] [![Release][release-image]][releases] [![License][license-image]][license] ## Overview -Add analytics to your Java software with the **[Snowplow] [snowplow]** event tracker for **[Java] [java]** (Android support is coming soon). +Add analytics to your Java software with the **[Snowplow][snowplow]** event tracker for **[Java][java]**. See also: **[Snowplow Android Tracker][snowplow-android-tracker]**. -With this tracker you can collect event data from your Java-based desktop and server apps, servlets and games. Supports JDK6+. +With this tracker you can collect event data from your Java-based desktop and server apps, servlets and games. Supports JDK8+. ## Find out more -| Technical Docs | Setup Guide | Roadmap | Contributing | -|---------------------------------|---------------------------|-------------------------|-----------------------------------| -| ![i1] [techdocs-image] | ![i2] [setup-image] | ![i3] [roadmap-image] | ![i4] [contributing-image] | -| **[Technical Docs] [techdocs]** | **[Setup Guide] [setup]** | **[Roadmap] [roadmap]** | **[Contributing] [contributing]** | +| Snowplow Docs | API Docs | Contributing | +|-------------------------------|-----------|-----------------------------------| +| ![i1][techdocs-image] | ![i1][techdocs-image] | ![i4][contributing-image] | +| **[Snowplow Docs][techdocs]** | **[Javadoc Docs][apidocs]** | **[Contributing](CONTRIBUTING.md)** | + +## Maintainer Quickstart + +Feedback and contributions are very welcome. If you have identified a bug, please log an issue on this repo. For all other feedback, discussion or questions please open a thread on our [Discourse forum][forums]. Feel free to make Pull Requests for new features, if you can code them yourself! + +Clone this repo and navigate into the cloned folder. To run the tests locally, you will need Docker or Java installed. Using either method, the build will fail if there are failing tests. + +To run the tests using Docker, run: + +```bash +$ docker build . -t snowplow-java-tracker +``` + +To run the tests using your installed JDK, run: + +```bash +$ ./gradlew build +``` + +We have also included a simple demo, found in the `examples/simple-console` folder. You will need a JDK installed to run it. When run, it sends several events to your event collector. For a simple event collector, we advise using the [Snowplow Micro][micro] testing pipeline. + +To run simple-console using the current Maven Central version of the Java tracker: +```bash +$ cd examples/simple-console +$ ./gradlew jar +$ java -jar ./build/libs/simple-console-all-0.0.1.jar "http://" +``` + +To run simple-console using a local version of the Java tracker: +```bash +$ ./gradlew publishToMavenLocal +$ cd examples/simple-console +$ ./gradlew jar +$ java -jar ./build/libs/simple-console-all-0.0.1.jar "http://" +``` ## Copyright and license -The Snowplow Java Tracker is copyright 2014 Snowplow Analytics Ltd. +The Snowplow Java Tracker is copyright 2014-present Snowplow Analytics Ltd. -Licensed under the **[Apache License, Version 2.0] [license]** (the "License"); +Licensed under the **[Apache License, Version 2.0][license]** (the "License"); you may not use this software except in compliance with the License. Unless required by applicable law or agreed to in writing, software @@ -28,24 +63,29 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +[github]: https://github.com/snowplow/snowplow-java-tracker/actions +[github-image]: https://github.com/snowplow/snowplow-java-tracker/workflows/Build/badge.svg + +[release-image]: https://img.shields.io/github/release/snowplow/snowplow-java-tracker.svg?style=flat +[releases]: https://github.com/snowplow/snowplow-java-tracker/releases + +[license-image]: https://img.shields.io/badge/license-Apache--2-blue.svg?style=flat +[license]: https://www.apache.org/licenses/LICENSE-2.0 + [java]: http://www.java.com/en/ [snowplow]: http://snowplowanalytics.com - -[dependencies]: https://drive.google.com/folderview?id=0B9v7AAtH8DSpWWZ1c3RUZjU3WlU&usp=sharing -[documentation]: https://gleasonk.github.io/Saggezza/JavaDoc/index.html +[forums]: https://discourse.snowplowanalytics.com/ +[snowplow-android-tracker]: https://github.com/snowplow/snowplow-android-tracker/ +[micro]: https://github.com/snowplow-incubator/snowplow-micro [techdocs-image]: https://d3i6fms1cm1j0i.cloudfront.net/github/images/techdocs.png [setup-image]: https://d3i6fms1cm1j0i.cloudfront.net/github/images/setup.png [roadmap-image]: https://d3i6fms1cm1j0i.cloudfront.net/github/images/roadmap.png [contributing-image]: https://d3i6fms1cm1j0i.cloudfront.net/github/images/contributing.png -[techdocs]: https://github.com/snowplow/snowplow/wiki/Android-and-Java-Tracker -[setup]: https://github.com/snowplow/snowplow/wiki/Java-Tracker-Setup -[roadmap]: https://github.com/snowplow/snowplow/wiki/Java-Tracker-Roadmap -[contributing]: https://github.com/snowplow/snowplow/wiki/Java-Tracker-Contributing - -[travis]: https://travis-ci.org/snowplow/snowplow-java-tracker -[travis-image]: https://travis-ci.org/snowplow/snowplow-java-tracker.svg?branch=master +[techdocs]: https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/java-tracker/ +[apidocs]: https://snowplow.github.io/snowplow-java-tracker/index.html?overview-summary.html -[license]: http://www.apache.org/licenses/LICENSE-2.0 +[tracker-classification]: https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/tracker-maintenance-classification/ +[maintained]: https://img.shields.io/static/v1?style=flat&label=Snowplow&message=Maintained&color=9e62dd&labelColor=9ba0aa&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAeFBMVEVMaXGXANeYANeXANZbAJmXANeUANSQAM+XANeMAMpaAJhZAJeZANiXANaXANaOAM2WANVnAKWXANZ9ALtmAKVaAJmXANZaAJlXAJZdAJxaAJlZAJdbAJlbAJmQAM+UANKZANhhAJ+EAL+BAL9oAKZnAKVjAKF1ALNBd8J1AAAAKHRSTlMAa1hWXyteBTQJIEwRgUh2JjJon21wcBgNfmc+JlOBQjwezWF2l5dXzkW3/wAAAHpJREFUeNokhQOCA1EAxTL85hi7dXv/E5YPCYBq5DeN4pcqV1XbtW/xTVMIMAZE0cBHEaZhBmIQwCFofeprPUHqjmD/+7peztd62dWQRkvrQayXkn01f/gWp2CrxfjY7rcZ5V7DEMDQgmEozFpZqLUYDsNwOqbnMLwPAJEwCopZxKttAAAAAElFTkSuQmCC diff --git a/build.gradle b/build.gradle index 3b50d72c..95b21d8e 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. * * This program is licensed to you under the Apache License Version 2.0, * and you may not use this file except in compliance with the Apache License Version 2.0. @@ -11,73 +11,91 @@ * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ -// Applying the java plugin to add support for Java -apply plugin: 'java' -// As per http://www.gradle.org/docs/current/userguide/publishing_maven.html -apply plugin: 'maven-publish' +import java.time.Duration -apply plugin: 'war' - -dependencies { - compile project(':snowplow-java-tracker-core') - runtime project(':snowplow-java-tracker-core') +plugins { + id 'java-library' + id 'maven-publish' + id 'io.github.gradle-nexus.publish-plugin' version '1.1.0' + id 'signing' + id 'idea' } -allprojects { - apply plugin: 'java' - group = 'com.snowplowanalytics' - version = '0.5.2' - sourceCompatibility = '1.6' - targetCompatibility = '1.6' - repositories { - // Use 'maven central' for resolving our dependencies - mavenCentral() - } -} +wrapper.gradleVersion = '6.5.0' +group = 'com.snowplowanalytics' +archivesBaseName = 'snowplow-java-tracker' +version = '2.1.0' +sourceCompatibility = '1.8' +targetCompatibility = '1.8' -task sourceJar(type: Jar, dependsOn: 'generateSources') { - from sourceSets.main.allJava +def javaVersion = JavaVersion.VERSION_1_8 + +repositories { + mavenCentral() } -// Publishing -publishing { - publications { - mavenJava(MavenPublication) { - from components.java +configure([compileJava, compileTestJava]) { + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + options.encoding = 'UTF-8' +} - artifact sourceJar { - classifier "sources" - } - } +java { + registerFeature('okhttpSupport') { + usingSourceSet(sourceSets.main) } - repositories { - maven { - url "$buildDir/repo" // change to point to your repo, e.g. http://my.org/repo - } + registerFeature('apachehttpSupport') { + usingSourceSet(sourceSets.main) } } -task testAll(dependsOn: assemble) { - subprojects.each {project -> - project.tasks.withType(Jar).each { - - // Dependencies for our production and test code - dependencies { - } - } +test { + useJUnitPlatform { + includeEngines 'junit-vintage' } } +dependencies { + // Apache Commons + api 'commons-net:commons-net:3.10.0' + api 'commons-codec:commons-codec:1.16.0' + + // Apache HTTP + apachehttpSupportApi 'org.apache.httpcomponents.client5:httpclient5:5.3' + + // Square OK HTTP + okhttpSupportApi 'com.squareup.okhttp3:okhttp:4.12.0' + + // SLF4J logging API + api 'org.slf4j:slf4j-api:2.0.11' + testImplementation 'org.slf4j:slf4j-simple:2.0.11' + + // Jackson JSON processor + api 'com.fasterxml.jackson.core:jackson-databind:2.16.1' + api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.1' + + // Testing libraries + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.1' + testCompileOnly 'junit:junit:4.13.2' + testRuntimeOnly 'org.junit.vintage:junit-vintage-engine' + + testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' +} + +task sourceJar(type: Jar, dependsOn: 'generateSources') { + from sourceSets.main.allJava +} + task generateSources { project.ext.set("outputDir", "$projectDir/src/main/java/com/snowplowanalytics/snowplow/tracker") doFirst { println outputDir - def srcFile = new File((String)outputDir, "Version.java") + def srcFile = new File((String) outputDir, "Version.java") srcFile.parentFile.mkdirs() srcFile.write( """/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. * * This program is licensed to you under the Apache License Version 2.0, * and you may not use this file except in compliance with the Apache License Version 2.0. @@ -88,9 +106,11 @@ task generateSources { * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ - package com.snowplowanalytics.snowplow.tracker; - +// DO NOT EDIT. AUTO-GENERATED. +/** +* The release version of the Snowplow Java tracker. +*/ public class Version { static final String TRACKER = "java-$project.version"; static final String VERSION = "$project.version"; @@ -101,3 +121,101 @@ public class Version { compileJava.dependsOn generateSources compileJava.source generateSources.outputs.files, sourceSets.main.java + + +task printVersion { + doLast { + print "$project.version" + } +} + +// custom tasks for creating source/javadoc jars +task sourcesJar(type: Jar, dependsOn: classes) { + classifier = 'sources' + from sourceSets.main.allSource +} + +task javadocJar(type: Jar, dependsOn: javadoc) { + classifier = 'javadoc' + from javadoc.destinationDir +} + +// add javadoc/source jar tasks as artifacts +artifacts { + archives sourcesJar, javadocJar +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + artifact sourcesJar + artifact javadocJar + groupId group + artifactId 'snowplow-java-tracker' + version version + suppressPomMetadataWarningsFor('apachehttpSupportApiElements') + suppressPomMetadataWarningsFor('apachehttpSupportRuntimeElements') + suppressPomMetadataWarningsFor('okhttpSupportApiElements') + suppressPomMetadataWarningsFor('okhttpSupportRuntimeElements') + pom { + name = 'snowplow-java-tracker' + description = 'Snowplow event tracker for Java. Add analytics to your Java desktop and server apps, servlets and games.' + url = 'https://github.com/snowplow/snowplow-java-tracker/' + inceptionYear = '2014' + + packaging = 'jar' + groupId = 'com.snowplowanalytics' + + licenses { + license { + name = 'Apache License, Version 2.0' + url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' + distribution = 'repo' + } + } + scm { + connection = 'https://github.com/snowplow/snowplow-java-tracker.git' + url = 'https://github.com/snowplow/snowplow-java-tracker' + } + developers { + developer { + name = 'Snowplow Analytics Ltd' + email = 'support@snowplowanalytics.com' + organization = 'Snowplow Analytics Ltd' + organizationUrl = 'http://snowplowanalytics.com' + } + } + organization { + name = 'com.snowplowanalytics' + url = 'http://snowplowanalytics.com' + } + } + } + } +} + +nexusPublishing { + repositories { + sonatype { + username = System.getenv('SONA_USER') + password = System.getenv('SONA_PASS') + } + } + transitionCheckOptions { + maxRetries.set(360) + delayBetween.set(Duration.ofSeconds(20)) + } +} + +signing { + if (System.getenv('ORG_GRADLE_PROJECT_signingKey') && System.getenv('ORG_GRADLE_PROJECT_signingPassword')) { + println 'Found signing credentials. Signing...' + def signingKey = findProperty("signingKey") + def signingPassword = findProperty("signingPassword") + useInMemoryPgpKeys(signingKey, signingPassword) + sign publishing.publications.mavenJava + println 'Used useInMemoryPgpKeys()' + } +} + diff --git a/examples/benchmarking/BenchmarkingREADME.md b/examples/benchmarking/BenchmarkingREADME.md new file mode 100644 index 00000000..7e0ff9e8 --- /dev/null +++ b/examples/benchmarking/BenchmarkingREADME.md @@ -0,0 +1,24 @@ +## Benchmarking results + +This benchmarking module is provided for maintainers, allowing them to check that their changes have not degraded performance. It uses the Java microbenchmarking harness, JMH. + +The benchmark test measures the time taken to track one event. Note that this does not include the time for the event to be processed and sent, which happens asynchronously. + +To run the test, navigate to this folder and run: + +```bash +$ ./gradlew build +$ ./gradlew jmh +``` + +The tracker version is set in the `build.gradle` file. Change the specified version to benchmark a different tracker version. +```groovy +dependencies { + jmh 'com.snowplowanalytics:snowplow-java-tracker:0.11.0' +} +``` +Note that you may also need to edit the `TrackerBenchmark` `closeThreads()` code. Versions from 0.12.0 onwards must call a different method. This is explained in in-line comments. + +### Results +See this PR for discussion of benchmarking results: https://github.com/snowplow/snowplow-java-tracker/pull/301 + diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/emitter/RequestCallback.java b/examples/benchmarking/build.gradle similarity index 59% rename from snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/emitter/RequestCallback.java rename to examples/benchmarking/build.gradle index 035668a6..279b1a8a 100644 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/emitter/RequestCallback.java +++ b/examples/benchmarking/build.gradle @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. * * This program is licensed to you under the Apache License Version 2.0, * and you may not use this file except in compliance with the Apache License Version 2.0. @@ -11,16 +11,26 @@ * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ -package com.snowplowanalytics.snowplow.tracker.core.emitter; +import org.gradle.api.tasks.JavaExec -import com.snowplowanalytics.snowplow.tracker.core.payload.Payload; +plugins { + id 'java' + id "me.champeau.jmh" version "0.6.6" +} -import java.util.List; +group 'com.snowplowanalytics' +version '1.0' -public interface RequestCallback { +repositories { + mavenLocal { + content { + includeGroup "com.snowplowanalytics" + } + } + mavenCentral() +} - void onSuccess(int successCount); - void onFailure(int successCount, List failedEvent); - -} \ No newline at end of file +dependencies { + jmh 'com.snowplowanalytics:snowplow-java-tracker:0.10.1' +} diff --git a/examples/benchmarking/gradle/wrapper/gradle-wrapper.jar b/examples/benchmarking/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..7454180f Binary files /dev/null and b/examples/benchmarking/gradle/wrapper/gradle-wrapper.jar differ diff --git a/snowplow-java-tracker-core/gradle/wrapper/gradle-wrapper.properties b/examples/benchmarking/gradle/wrapper/gradle-wrapper.properties similarity index 80% rename from snowplow-java-tracker-core/gradle/wrapper/gradle-wrapper.properties rename to examples/benchmarking/gradle/wrapper/gradle-wrapper.properties index 44d0fd0e..69a97150 100644 --- a/snowplow-java-tracker-core/gradle/wrapper/gradle-wrapper.properties +++ b/examples/benchmarking/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Tue Jun 24 14:10:30 EDT 2014 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-1.12-all.zip diff --git a/snowplow-java-tracker-core/gradlew b/examples/benchmarking/gradlew similarity index 62% rename from snowplow-java-tracker-core/gradlew rename to examples/benchmarking/gradlew index 91a7e269..744e882e 100755 --- a/snowplow-java-tracker-core/gradlew +++ b/examples/benchmarking/gradlew @@ -1,4 +1,20 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ############################################################################## ## @@ -6,20 +22,38 @@ ## ############################################################################## -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" -warn ( ) { +warn () { echo "$*" } -die ( ) { +die () { echo echo "$*" echo @@ -30,6 +64,7 @@ die ( ) { cygwin=false msys=false darwin=false +nonstop=false case "`uname`" in CYGWIN* ) cygwin=true @@ -37,36 +72,17 @@ case "`uname`" in Darwin* ) darwin=true ;; - MINGW* ) + MSYS* | MINGW* ) msys=true ;; + NONSTOP* ) + nonstop=true + ;; esac -# For Cygwin, ensure paths are in UNIX format before anything is touched. -if $cygwin ; then - [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` -fi - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >&- -APP_HOME="`pwd -P`" -cd "$SAVED" >&- - CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then @@ -90,7 +106,7 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then @@ -110,11 +126,13 @@ if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` SEP="" @@ -138,27 +156,30 @@ if $cygwin ; then else eval `echo args$i`="\"$arg\"" fi - i=$((i+1)) + i=`expr $i + 1` done case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " } -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" +exec "$JAVACMD" "$@" diff --git a/examples/benchmarking/gradlew.bat b/examples/benchmarking/gradlew.bat new file mode 100644 index 00000000..ac1b06f9 --- /dev/null +++ b/examples/benchmarking/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/benchmarking/settings.gradle b/examples/benchmarking/settings.gradle new file mode 100644 index 00000000..9efe9f92 --- /dev/null +++ b/examples/benchmarking/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'benchmarking' + diff --git a/examples/benchmarking/src/jmh/java/com/snowplowanalytics/TrackerBenchmark.java b/examples/benchmarking/src/jmh/java/com/snowplowanalytics/TrackerBenchmark.java new file mode 100644 index 00000000..2852b657 --- /dev/null +++ b/examples/benchmarking/src/jmh/java/com/snowplowanalytics/TrackerBenchmark.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics; + +import com.snowplowanalytics.snowplow.tracker.Tracker; +import com.snowplowanalytics.snowplow.tracker.emitter.BatchEmitter; +import com.snowplowanalytics.snowplow.tracker.emitter.Emitter; +import com.snowplowanalytics.snowplow.tracker.events.PageView; +import com.snowplowanalytics.snowplow.tracker.http.HttpClientAdapter; +import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +import org.openjdk.jmh.annotations.*; +import org.openjdk.jmh.infra.Blackhole; + +import java.util.concurrent.TimeUnit; + + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 15, time = 500, timeUnit = TimeUnit.MILLISECONDS) +@Measurement(iterations = 20, time = 500, timeUnit = TimeUnit.MILLISECONDS) +@Fork(5) +public class TrackerBenchmark { + public static class MockHttpClientAdapter implements HttpClientAdapter { + @Override + public int post(SelfDescribingJson payload) { + return 200; + } + + @Override + public int get(TrackerPayload payload) { + return 0; + } + + @Override + public String getUrl() { + return null; + } + + @Override + public Object getHttpClient() { + return null; + } + } + + public static BatchEmitter getEmitter() { + MockHttpClientAdapter mockHttpClientAdapter = new MockHttpClientAdapter(); + return BatchEmitter.builder() + .httpClientAdapter(mockHttpClientAdapter) + .build(); + } + + public static Tracker getTracker(Emitter emitter) { + return new Tracker.TrackerBuilder(emitter, "namespace", "appId").build(); + } + + public static void closeThreads(Tracker tracker) { + BatchEmitter emitter = (BatchEmitter) tracker.getEmitter(); + emitter.close(); + } + + // This State class exists only to print out the tracker version + @State(Scope.Benchmark) + public static class TrackerVersion { + BatchEmitter emitter = getEmitter(); + Tracker tracker = getTracker(emitter); + + @Setup(Level.Trial) + public void printTrackerVersion() { + System.out.println("Using tracker version: " + tracker.getTrackerVersion()); + } + + @TearDown(Level.Trial) + public void doTearDown() { + System.out.println("Do TearDown for trackerVersion state"); + closeThreads(tracker); + } + } + + // This class creates the tracker components. + // They are recreated for every iteration of the benchmark test. + @State(Scope.Benchmark) + public static class TrackerComponents { + Tracker tracker; + BatchEmitter emitter; + + PageView pageViewEvent = PageView.builder() + .pageUrl("url") + .pageTitle("title") + .referrer("referrer") + .build(); + + @Setup(Level.Iteration) + public void doSetUp() { + emitter = getEmitter(); + tracker = getTracker(emitter); + } + + @TearDown(Level.Iteration) + public void doTearDown() { + closeThreads(tracker); + } + } + + // The Blackhole forces JMH to measure the method. + @Benchmark + public void testTrackEvent(Blackhole blackhole, TrackerComponents trackerComponents, TrackerVersion trackerVersion) { + trackerComponents.tracker.track(trackerComponents.pageViewEvent); + blackhole.consume(trackerComponents); + } +} diff --git a/examples/simple-console/.gitignore b/examples/simple-console/.gitignore new file mode 100644 index 00000000..a7458c28 --- /dev/null +++ b/examples/simple-console/.gitignore @@ -0,0 +1,3 @@ +build/ +out/ + diff --git a/examples/simple-console/README.md b/examples/simple-console/README.md new file mode 100644 index 00000000..a089df68 --- /dev/null +++ b/examples/simple-console/README.md @@ -0,0 +1,12 @@ +# Simple console sample + +This is a small Java console project that sends PageView events to a given collector. + +## Run + +Ensure `build.gradle` is using a version available on the Snowplow maven repository, or a version installed to a local maven repository. + +```bash +./gradlew jar + java -jar ./build/libs/simple-console-all-0.0.1.jar "http://" +``` diff --git a/examples/simple-console/build.gradle b/examples/simple-console/build.gradle new file mode 100644 index 00000000..0b134e77 --- /dev/null +++ b/examples/simple-console/build.gradle @@ -0,0 +1,50 @@ +apply plugin: 'java' +group = 'com.snowplowanalytics' +version = '0.0.1' +sourceCompatibility = '1.8' +targetCompatibility = '1.8' + +repositories { + mavenLocal() + mavenCentral() +} + +test { + useJUnitPlatform { + includeEngines 'junit-vintage' + } +} + +dependencies { + implementation 'com.snowplowanalytics:snowplow-java-tracker:2.+' + + implementation ('com.snowplowanalytics:snowplow-java-tracker:2.+') { + capabilities { + requireCapability 'com.snowplowanalytics:snowplow-java-tracker-okhttp-support' + } + } + + implementation 'org.slf4j:slf4j-simple:1.7.36' + + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' + testCompileOnly 'junit:junit:4.13.2' + testRuntimeOnly 'org.junit.vintage:junit-vintage-engine' +} + +task fatJar(type: Jar) { + manifest { + attributes 'Implementation-Title': 'simple-console', + 'Implementation-Version': version, + 'Main-Class': 'com.snowplowanalytics.Main' + } + baseName = project.name + '-all' + from { + configurations.compileClasspath.collect { + it.isDirectory() ? it : zipTree(it) + } + } + with jar + duplicatesStrategy DuplicatesStrategy.EXCLUDE +} + +tasks.jar.dependsOn(fatJar) diff --git a/examples/simple-console/gradle/wrapper/gradle-wrapper.jar b/examples/simple-console/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..7454180f Binary files /dev/null and b/examples/simple-console/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/simple-console/gradle/wrapper/gradle-wrapper.properties b/examples/simple-console/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..ffed3a25 --- /dev/null +++ b/examples/simple-console/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/simple-console/gradlew b/examples/simple-console/gradlew new file mode 100755 index 00000000..f887d101 --- /dev/null +++ b/examples/simple-console/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2022 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/simple-console/gradlew.bat b/examples/simple-console/gradlew.bat new file mode 100644 index 00000000..ac1b06f9 --- /dev/null +++ b/examples/simple-console/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/simple-console/src/main/java/com/snowplowanalytics/Main.java b/examples/simple-console/src/main/java/com/snowplowanalytics/Main.java new file mode 100644 index 00000000..e0f59265 --- /dev/null +++ b/examples/simple-console/src/main/java/com/snowplowanalytics/Main.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ + +package com.snowplowanalytics; + +import com.snowplowanalytics.snowplow.tracker.Snowplow; +import com.snowplowanalytics.snowplow.tracker.Subject; +import com.snowplowanalytics.snowplow.tracker.Tracker; +import com.snowplowanalytics.snowplow.tracker.configuration.EmitterConfiguration; +import com.snowplowanalytics.snowplow.tracker.configuration.NetworkConfiguration; +import com.snowplowanalytics.snowplow.tracker.configuration.TrackerConfiguration; +import com.snowplowanalytics.snowplow.tracker.events.*; +import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; + +import java.util.Collections; +import static java.util.Collections.singletonList; +import java.util.List; + + +public class Main { + + public static String getUrlFromArgs(String[] args) { + if (args == null || args.length < 1) { + throw new IllegalArgumentException("Collector URL is required"); + } + return args[0]; + } + + public static void main(String[] args) throws InterruptedException { + String collectorEndpoint = getUrlFromArgs(args); + + // the application id to attach to events + String appId = "java-tracker-sample-console-app"; + // the namespace to attach to events + String namespace = "demo"; + + // The easiest way to build a tracker is with configuration classes + TrackerConfiguration trackerConfig = new TrackerConfiguration(namespace, appId); + NetworkConfiguration networkConfig = new NetworkConfiguration(collectorEndpoint); + EmitterConfiguration emitterConfig = new EmitterConfiguration().batchSize(4); // send batches of 4 events. In production this number should be higher, depending on the size/event volume + + // We need a tracker to turn our events into something a Snowplow collector can understand + final Tracker tracker = Snowplow.createTracker(trackerConfig, networkConfig, emitterConfig); + + System.out.println("Sending events to " + collectorEndpoint); + System.out.println("Using tracker version " + tracker.getTrackerVersion()); + + // This is an example of a custom context entity + List context = singletonList( + new SelfDescribingJson( + "iglu:com.snowplowanalytics.iglu/anything-c/jsonschema/1-0-0", + Collections.singletonMap("foo", "bar"))); + + // This is an example of a eventSubject for adding user data + Subject eventSubject = new Subject(); + eventSubject.setUserId("example@snowplowanalytics.com"); + eventSubject.setLanguage("EN"); + + // This is a sample page view event + // the eventSubject has been included in this event + PageView pageViewEvent = PageView.builder() + .pageTitle("Snowplow Analytics") + .pageUrl("https://www.snowplowanalytics.com") + .referrer("https://www.google.com") + .customContext(context) + .subject(eventSubject) + .build(); + + // EcommerceTransactions will be deprecated soon: we advise using SelfDescribing events instead + // EcommerceTransactionItems are tracked as part of an EcommerceTransaction event + // They are processed into separate events during the `track()` call + EcommerceTransactionItem item = EcommerceTransactionItem.builder() + .itemId("order_id") + .sku("sku") + .price(1.0) + .quantity(2) + .name("name") + .category("category") + .currency("EUR") + .customContext(context) + .build(); + + // EcommerceTransaction event + EcommerceTransaction ecommerceTransaction = EcommerceTransaction.builder() + .orderId("order_id") + .totalValue(1.0) + .affiliation("affiliation") + .taxValue(2.0) + .shipping(3.0) + .city("city") + .state("state") + .country("country") + .currency("EUR") + .items(item) // EcommerceTransactionItem events are added to a parent EcommerceTransaction here + .customContext(context) + .build(); + + + // This is an example of a custom SelfDescribing event based on a schema + SelfDescribing selfDescribing = SelfDescribing.builder() + .eventData(new SelfDescribingJson( + "iglu:com.snowplowanalytics.iglu/anything-a/jsonschema/1-0-0", + Collections.singletonMap("foo", "bar") + )) + .customContext(context) + .build(); + + + // This is an example of a ScreenView event which will be translated into a SelfDescribing event + ScreenView screenView = ScreenView.builder() + .name("name") + .id("id") + .customContext(context) + .build(); + + + // This is an example of a Timing event which will be translated into a SelfDescribing event + Timing timing = Timing.builder() + .category("category") + .label("label") + .variable("variable") + .timing(10) + .customContext(context) + .build(); + + // This is an example of a Structured event + Structured structured = Structured.builder() + .category("category") + .action("action") + .label("label") + .property("property") + .value(12.34) + .customContext(context) + .build(); + + tracker.track(pageViewEvent); // the .track method schedules the event for delivery to Snowplow + tracker.track(ecommerceTransaction); // This will track two events + tracker.track(selfDescribing); + tracker.track(screenView); + tracker.track(timing); + tracker.track(structured); + + // Will close all threads and force send remaining events + tracker.close(); + Thread.sleep(5000); + + System.out.println("Tracked 7 events"); + } + +} diff --git a/examples/simple-console/src/test/java/com/snowplowanalytics/MainTest.java b/examples/simple-console/src/test/java/com/snowplowanalytics/MainTest.java new file mode 100644 index 00000000..233ce2d8 --- /dev/null +++ b/examples/simple-console/src/test/java/com/snowplowanalytics/MainTest.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ + +package com.snowplowanalytics; + +import com.snowplowanalytics.snowplow.tracker.http.HttpClientAdapter; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class MainTest { + + @Test + public void testGetUrl() { + String[] sample = {"com.acme", "world"}; + assertEquals("com.acme", Main.getUrlFromArgs(sample)); + } + + @Test(expected = IllegalArgumentException.class) + public void testGetUrlNull() { + Main.getUrlFromArgs(null); + } + + @Test(expected = IllegalArgumentException.class) + public void testGetUrlEmpty() { + Main.getUrlFromArgs(new String[]{}); + } + +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 8c0fb64a..62d4c053 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 44d0fd0e..ffed3a25 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Tue Jun 24 14:10:30 EDT 2014 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-1.12-all.zip diff --git a/gradlew b/gradlew index 91a7e269..fbd7c515 100755 --- a/gradlew +++ b/gradlew @@ -1,4 +1,20 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ############################################################################## ## @@ -6,20 +22,38 @@ ## ############################################################################## -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" -warn ( ) { +warn () { echo "$*" } -die ( ) { +die () { echo echo "$*" echo @@ -30,6 +64,7 @@ die ( ) { cygwin=false msys=false darwin=false +nonstop=false case "`uname`" in CYGWIN* ) cygwin=true @@ -40,33 +75,14 @@ case "`uname`" in MINGW* ) msys=true ;; + NONSTOP* ) + nonstop=true + ;; esac -# For Cygwin, ensure paths are in UNIX format before anything is touched. -if $cygwin ; then - [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` -fi - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >&- -APP_HOME="`pwd -P`" -cd "$SAVED" >&- - CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then @@ -90,7 +106,7 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then @@ -110,10 +126,12 @@ if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` @@ -138,27 +156,30 @@ if $cygwin ; then else eval `echo args$i`="\"$arg\"" fi - i=$((i+1)) + i=`expr $i + 1` done case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " } -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" +exec "$JAVACMD" "$@" diff --git a/snowplow-java-tracker-core/gradlew.bat b/gradlew.bat similarity index 68% rename from snowplow-java-tracker-core/gradlew.bat rename to gradlew.bat index aec99730..a9f778a7 100644 --- a/snowplow-java-tracker-core/gradlew.bat +++ b/gradlew.bat @@ -1,3 +1,19 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @@ -8,14 +24,17 @@ @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome @@ -46,10 +65,9 @@ echo location of your Java installation. goto fail :init -@rem Get command-line arguments, handling Windowz variants +@rem Get command-line arguments, handling Windows variants if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args :win9xME_args @rem Slurp the command line arguments. @@ -60,17 +78,13 @@ set _SKIP=2 if "x%~1" == "x" goto execute set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index ef042527..00000000 --- a/settings.gradle +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ - -rootProject.name = 'snowplow-java-tracker' -include 'snowplow-java-tracker-core' diff --git a/snowplow-java-tracker-core/build.gradle b/snowplow-java-tracker-core/build.gradle deleted file mode 100644 index f33a574c..00000000 --- a/snowplow-java-tracker-core/build.gradle +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ - -// Applying the java plugin to add support for Java -apply plugin: 'java' -// As per http://www.gradle.org/docs/current/userguide/publishing_maven.html -apply plugin: 'maven-publish' - -group = 'com.snowplowanalytics' -version = '0.1.4' - -// Where to find the dependencies of our project -repositories { - // Use 'maven central' for resolving our dependencies - mavenCentral() -} - -// Dependencies for our production and test code -dependencies { - - // Apache Commons - compile 'commons-codec:commons-codec:1.2' - compile 'commons-net:commons-net:3.3' - - // Apache HTTP - compile 'org.apache.httpcomponents:httpclient:4.3.3' - compile 'org.apache.httpcomponents:httpasyncclient:4.0.1' - - // SLF4J logging API - compile 'org.slf4j:slf4j-simple:1.7.7' - - // Jackson JSON processor - compile 'com.fasterxml.jackson.core:jackson-databind:2.4.1.1' - - // Contracts - compile 'com.google.guava:guava:17.0' - - testCompile 'junit:junit:4.11' -} - -task sourceJar(type: Jar, dependsOn: 'generateSources') { - from sourceSets.main.allJava -} - -// Publishing -publishing { - publications { - mavenJava(MavenPublication) { - from components.java - - artifact sourceJar { - classifier "sources" - } - } - } - repositories { - maven { - url "$buildDir/repo" // change to point to your repo, e.g. http://my.org/repo - } - } -} - -task generateSources { - project.ext.set("outputDir", "$projectDir/src/main/java/com/snowplowanalytics/snowplow/tracker/core") - doFirst { - println outputDir - def srcFile = new File((String)outputDir, "Version.java") - srcFile.parentFile.mkdirs() - srcFile.write( -"""/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ - -package com.snowplowanalytics.snowplow.tracker.core; - -public class Version { - static final String TRACKER = "java-core-$project.version"; - static final String VERSION = "$project.version"; -} -""") - } -} - -compileJava.dependsOn generateSources -compileJava.source generateSources.outputs.files, sourceSets.main.java diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Base64.java b/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Base64.java deleted file mode 100644 index 2a4220fc..00000000 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Base64.java +++ /dev/null @@ -1,261 +0,0 @@ -/** - * Copyright 2005-2013 Karl Roberts - * - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions - * are met: - * 1. Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * 3. Neither the name of the author nor the names of his contributors - * may be used to endorse or promote products derived from this software - * without specific prior written permission. - - * THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE - * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS - * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) - * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT - * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY - * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF - * SUCH DAMAGE. - */ - -package com.snowplowanalytics.snowplow.tracker.core; - -import java.io.UnsupportedEncodingException; - -/** - * Author: karl roberts - * Date: 2-Mar-2005 - * Time: 15:34:26 - * - * Thanks to Tom Daley who posted the - * Base 64encode algorithm at - * http://www.javaworld.com/javaworld/javatips/jw-javatip36-p2.html - * allowing me to create the decoding - * - * the Base64 encoding can be represented by - * - * @author jkyr - * - */ -public class Base64 { - - public static final String DEFAULT_ENCODING = "UTF-8"; - - /* - * The methods of this class are static. Do not instantiate this class. Use - * its static methods to get the encoded/decoded results - */ - public static String encode(byte[] byteData) throws UnsupportedEncodingException { - return encode(byteData, DEFAULT_ENCODING); - } - public static String encode(byte[] byteData, String encoding) throws UnsupportedEncodingException { - if(byteData == null) { throw new IllegalArgumentException("byteData cannot be null"); } - return new String(_encode(byteData),encoding); - } - - public static byte[] encode(String string) throws UnsupportedEncodingException { - return encode(string, DEFAULT_ENCODING); - } - - public static byte[] encode(String string, String encoding) throws UnsupportedEncodingException { - if(string == null) { throw new IllegalArgumentException("string cannot be null"); } - return _encode(string.getBytes(encoding)); - } - - public final static byte[] _encode(byte[] byteData) { - /* If we received a null argument, exit this method. */ - if (byteData == null) { throw new IllegalArgumentException("byteData cannot be null"); } - - /* - * Declare working variables including an array of bytes that will - * contain the encoded data to be returned to the caller. Note that the - * encoded array is about 1/3 larger than the input. This is because - * every group of 3 bytes is being encoded into 4 bytes. - */ - int iSrcIdx; // index into source (byteData) - int iDestIdx; // index into destination (byteDest) - // byte[] byteData = (byte[])byteData_in.clone(); - // byte[] byteData = byteData_in; - byte[] byteDest = new byte[((byteData.length + 2) / 3) * 4]; - - /* - * Walk through the input array, 24 bits at a time, converting them from - * 3 groups of 8 to 4 groups of 6 with two unset bits between. as per - * Base64 spec see - * http://www.javaworld.com/javaworld/javatips/jw-javatip36-p2.html for - * example explanation - */ - for (iSrcIdx = 0, iDestIdx = 0; iSrcIdx < byteData.length - 2; iSrcIdx += 3) { - byteDest[iDestIdx++] = (byte) ((byteData[iSrcIdx] >>> 2) & 077); - byteDest[iDestIdx++] = (byte) ((byteData[iSrcIdx + 1] >>> 4) & 017 | (byteData[iSrcIdx] << 4) & 077); - byteDest[iDestIdx++] = (byte) ((byteData[iSrcIdx + 2] >>> 6) & 003 | (byteData[iSrcIdx + 1] << 2) & 077); - byteDest[iDestIdx++] = (byte) (byteData[iSrcIdx + 2] & 077); - } - - /* - * If the number of bytes we received in the input array was not an even - * multiple of 3, convert the remaining 1 or 2 bytes. - */ - if (iSrcIdx < byteData.length) { - byteDest[iDestIdx++] = (byte) ((byteData[iSrcIdx] >>> 2) & 077); - if (iSrcIdx < byteData.length - 1) { - byteDest[iDestIdx++] = (byte) ((byteData[iSrcIdx + 1] >>> 4) & 017 | (byteData[iSrcIdx] << 4) & 077); - byteDest[iDestIdx++] = (byte) ((byteData[iSrcIdx + 1] << 2) & 077); - } else - byteDest[iDestIdx++] = (byte) ((byteData[iSrcIdx] << 4) & 077); - } - - /* - * Use the encoded data as indexes into the Base64 alphabet. (The Base64 - * alphabet is completely documented in RFC 1521.) - */ - for (iSrcIdx = 0; iSrcIdx < iDestIdx; iSrcIdx++) { - if (byteDest[iSrcIdx] < 26) - byteDest[iSrcIdx] = (byte) (byteDest[iSrcIdx] + 'A'); - else if (byteDest[iSrcIdx] < 52) - byteDest[iSrcIdx] = (byte) (byteDest[iSrcIdx] + 'a' - 26); - else if (byteDest[iSrcIdx] < 62) - byteDest[iSrcIdx] = (byte) (byteDest[iSrcIdx] + '0' - 52); - else if (byteDest[iSrcIdx] < 63) - byteDest[iSrcIdx] = '+'; - else - byteDest[iSrcIdx] = '/'; - } - - /* Pad any unused bytes in the destination string with '=' characters. */ - for (; iSrcIdx < byteDest.length; iSrcIdx++) - byteDest[iSrcIdx] = '='; - - return byteDest; - } - - public static String decode(byte[] encoded) throws UnsupportedEncodingException { - return decode(encoded, DEFAULT_ENCODING); - } - - public static String decode(byte[] encoded, String encoding) throws UnsupportedEncodingException { - if(encoded == null) { throw new IllegalArgumentException("encoded cannot be null"); } - return new String(_decode(encoded), encoding); - } - - public final static byte[] decode(String encoded) throws UnsupportedEncodingException { - return decode(encoded,DEFAULT_ENCODING); - } - - public final static byte[] decode(String encoded, String encoding) throws IllegalArgumentException, UnsupportedEncodingException { - if(null == encoded) { throw new IllegalArgumentException("encoded cannot be null"); } - return _decode(encoded.getBytes(encoding)); - } - - public final static byte[] _decode(byte[] byteData) throws IllegalArgumentException { - /* If we received a null argument, exit this method. */ - if (byteData == null) { throw new IllegalArgumentException("byteData cannot be null"); } - - /* - * Declare working variables including an array of bytes that will - * contain the decoded data to be returned to the caller. Note that the - * decoded array is about 3/4 smaller than the input. This is because - * every group of 4 bytes is being encoded into 3 bytes. - */ - int iSrcIdx; // index into source (byteData) - int reviSrcIdx; // index from end of the src array (byteData) - int iDestIdx; // index into destination (byteDest) - byte[] byteTemp = new byte[byteData.length]; - - /* - * remove any '=' chars from the end of the byteData they would have - * been padding to make it up to groups of 4 bytes note that I don't - * need to remove it just make sure that when progressing throug array - * we don't go past reviSrcIdx ;-) - */ - for (reviSrcIdx = byteData.length; reviSrcIdx -1 > 0 && byteData[reviSrcIdx -1] == '='; reviSrcIdx--) { - ; // do nothing. I'm just interested in value of reviSrcIdx - } - - /* sanity check */ - if (reviSrcIdx -1 == 0) { return null; /* ie all padding */ } - - /* - * Set byteDest, this is smaller than byteData due to 4 -> 3 byte munge. - * Note that this is an integer division! This fact is used in the logic - * l8r. to make sure we don't fall out of the array and create an - * OutOfBoundsException and also in handling the remainder - */ - byte byteDest[] = new byte[((reviSrcIdx * 3) / 4)]; - - /* - * Convert from Base64 alphabet to encoded data (The Base64 alphabet is - * completely documented in RFC 1521.) The order of the testing is - * important as I use the '<' operator which looks at the hex value of - * these ASCII chars. So convert from the smallest up - * - * do all of this in a new array so as not to edit the original input - */ - for (iSrcIdx = 0; iSrcIdx < reviSrcIdx; iSrcIdx++) { - if (byteData[iSrcIdx] == '+') - byteTemp[iSrcIdx] = 62; - else if (byteData[iSrcIdx] == '/') - byteTemp[iSrcIdx] = 63; - else if (byteData[iSrcIdx] < '0' + 10) - byteTemp[iSrcIdx] = (byte) (byteData[iSrcIdx] + 52 - '0'); - else if (byteData[iSrcIdx] < ('A' + 26)) - byteTemp[iSrcIdx] = (byte) (byteData[iSrcIdx] - 'A'); - else if (byteData[iSrcIdx] < 'a' + 26) - byteTemp[iSrcIdx] = (byte) (byteData[iSrcIdx] + 26 - 'a'); - } - - /* - * 4bytes -> 3bytes munge Walk through the input array, 32 bits at a - * time, converting them from 4 groups of 6 to 3 groups of 8 removing - * the two unset most significant bits of each sorce byte as this was - * filler, as per Base64 spec. stop before potential buffer overun on - * byteDest, remember that byteDest is 3/4 (integer division) the size - * of input and won't necessary divide exactly (ie iDestIdx must be < - * (integer div byteDest.length / 3)*3 see - * http://www.javaworld.com/javaworld/javatips/jw-javatip36-p2.html for - * example - */ - for (iSrcIdx = 0, iDestIdx = 0; iSrcIdx < reviSrcIdx - && iDestIdx < ((byteDest.length / 3) * 3); iSrcIdx += 4) { - byteDest[iDestIdx++] = (byte) ((byteTemp[iSrcIdx] << 2) & 0xFC | (byteTemp[iSrcIdx + 1] >>> 4) & 0x03); - byteDest[iDestIdx++] = (byte) ((byteTemp[iSrcIdx + 1] << 4) & 0xF0 | (byteTemp[iSrcIdx + 2] >>> 2) & 0x0F); - byteDest[iDestIdx++] = (byte) ((byteTemp[iSrcIdx + 2] << 6) & 0xC0 | byteTemp[iSrcIdx + 3] & 0x3F); - } - - /* - * tidy up any remainders if iDestIdx >= ((byteDest.length / 3)*3) but - * iSrcIdx < reviSrcIdx then we have at most 2 extra destination bytes - * to fill and posiblr 3 input bytes yet to process - */ - if (iSrcIdx < reviSrcIdx) { - if (iSrcIdx < reviSrcIdx - 2) { - // "3 input bytes left" - byteDest[iDestIdx++] = (byte) ((byteTemp[iSrcIdx] << 2) & 0xFC | (byteTemp[iSrcIdx + 1] >>> 4) & 0x03); - byteDest[iDestIdx++] = (byte) ((byteTemp[iSrcIdx + 1] << 4) & 0xF0 | (byteTemp[iSrcIdx + 2] >>> 2) & 0x0F); - } else if (iSrcIdx < reviSrcIdx - 1) { - // "2 input bytes left" - byteDest[iDestIdx++] = (byte) ((byteTemp[iSrcIdx] << 2) & 0xFC | (byteTemp[iSrcIdx + 1] >>> 4) & 0x03); - } - /* - * wont have just one input byte left (unless input wasn't base64 - * encoded ) due to the for loop steps and array sizes, after "=" - * pad removed, but for compleatness - */ - else { - throw new IllegalArgumentException("Warning: 1 input bytes left to process. This was not Base64 input"); - } - } - return byteDest; - } - -} // that's all folks! \ No newline at end of file diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Constants.java b/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Constants.java deleted file mode 100644 index 2458f5a7..00000000 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Constants.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ - -package com.snowplowanalytics.snowplow.tracker.core; - -public class Constants { - public static final String DEFAULT_VENDOR = "com.snowplowanalytics.snowplow"; - public static final String DEFAULT_IGLU_VENDOR = "iglu:com.snowplowanalytics.snowplow"; - public static final String DEFAULT_SCHEMA_TAG = "jsonschema"; - public static final String DEFAULT_SCHEMA_VERSION = "1-0-0"; - public static final String SCHEMA_PAYLOAD_DATA = "iglu:com.snowplowanalytics.snowplow/payload_data/jsonschema/1-0-0"; - - public static final String EVENT_PAGE_VIEW = "pv"; - public static final String EVENT_STRUCTURED = "se"; - public static final String EVENT_UNSTRUCTURED = "ue"; - public static final String EVENT_ECOMM = "tr"; - public static final String EVENT_ECOMM_ITEM = "ti"; -} \ No newline at end of file diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Subject.java b/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Subject.java deleted file mode 100644 index a0e6c104..00000000 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Subject.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ - -package com.snowplowanalytics.snowplow.tracker.core; - -import java.util.Calendar; -import java.util.HashMap; -import java.util.Map; -import java.util.TimeZone; - -public class Subject { - - private HashMap standardPairs; - - public Subject() { - standardPairs = new HashMap(); - - // Default Platform - this.setPlatform(DevicePlatform.Desktop); - - // Default Timezone - TimeZone tz = Calendar.getInstance().getTimeZone(); - this.setTimezone(tz.getID()); - } - - public void setPlatform(DevicePlatform platform) { - this.standardPairs.put(Parameter.PLATFORM, platform.toString()); - } - - public void setUserId(String userId) { - this.standardPairs.put(Parameter.UID, userId); - } - - public void setScreenResolution(int width, int height) { - String res = Integer.toString(width) + "x" + Integer.toString(height); - this.standardPairs.put(Parameter.RESOLUTION, res); - } - - public void setViewPort(int width, int height) { - String res = Integer.toString(width) + "x" + Integer.toString(height); - this.standardPairs.put(Parameter.VIEWPORT, res); - } - - public void setColorDepth(int depth) { - this.standardPairs.put(Parameter.COLOR_DEPTH, Integer.toString(depth)); - } - - public void setTimezone(String timezone) { - this.standardPairs.put(Parameter.TIMEZONE, timezone); - } - - public void setLanguage(String language) { - this.standardPairs.put(Parameter.LANGUAGE, language); - } - - public Map getSubject() { - return this.standardPairs; - } -} diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Tracker.java b/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Tracker.java deleted file mode 100644 index f364bce7..00000000 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Tracker.java +++ /dev/null @@ -1,551 +0,0 @@ -/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ - -package com.snowplowanalytics.snowplow.tracker.core; - -import com.google.common.base.Preconditions; -import com.snowplowanalytics.snowplow.tracker.core.emitter.Emitter; -import com.snowplowanalytics.snowplow.tracker.core.payload.Payload; -import com.snowplowanalytics.snowplow.tracker.core.payload.SchemaPayload; -import com.snowplowanalytics.snowplow.tracker.core.payload.TrackerPayload; - -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; - -public class Tracker { - - private boolean base64Encoded = true; - private Emitter emitter; - private String appId; - private String namespace; - private String contextSchema; - private String baseSchemaPath; - private String schemaTag; - private String schemaVersion; - private String trackerVersion; - private String unstructSchema; - private Subject subject; - - /** - * @param emitter Emitter to which events will be sent - * @param namespace Identifier for the Tracker instance - * @param appId Application ID - */ - public Tracker(Emitter emitter, String namespace, String appId) { - this(emitter, null, namespace, appId, true); - } - - /** - * @param emitter Emitter to which events will be sent - * @param subject Subject to be tracked - * @param namespace Identifier for the Tracker instance - * @param appId Application ID - */ - public Tracker(Emitter emitter, Subject subject, String namespace, String appId) { - this(emitter, subject, namespace, appId, true); - } - - /** - * @param emitter Emitter to which events will be sent - * @param namespace Identifier for the Tracker instance - * @param appId Application ID - * @param base64Encoded Whether JSONs in the payload should be base-64 encoded - */ - public Tracker(Emitter emitter, String namespace, String appId, boolean base64Encoded) { - this(emitter, null, namespace, appId, base64Encoded); - } - - /** - * @param emitter Emitter to which events will be sent - * @param subject Subject to be tracked - * @param namespace Identifier for the Tracker instance - * @param appId Application ID - * @param base64Encoded Whether JSONs in the payload should be base-64 encoded - */ - public Tracker(Emitter emitter, Subject subject, String namespace, String appId, - boolean base64Encoded) { - this.emitter = emitter; - this.appId = appId; - this.base64Encoded = base64Encoded; - this.namespace = namespace; - this.subject = subject; - this.trackerVersion = Version.TRACKER; - this.setSchema(Constants.DEFAULT_IGLU_VENDOR, Constants.DEFAULT_SCHEMA_TAG, - Constants.DEFAULT_SCHEMA_VERSION); - } - - /** - * @param payload Payload builder - * @param context Custom context for the event - * @param timestamp Optional user-provided timestamp for the event - * @return A completed Payload - */ - protected Payload completePayload(Payload payload, List context, - long timestamp) { - payload.add(Parameter.APPID, this.appId); - payload.add(Parameter.NAMESPACE, this.namespace); - payload.add(Parameter.TRACKER_VERSION, this.trackerVersion); - payload.add(Parameter.EID, Util.getEventId()); - - // If timestamp is set to 0, generate one - payload.add(Parameter.TIMESTAMP, - (timestamp == 0 ? Util.getTimestamp() : Long.toString(timestamp))); - - // Encodes context data - if (context != null) { - SchemaPayload envelope = new SchemaPayload(); - envelope.setSchema(contextSchema); - - // We can do better here, rather than re-iterate through the list - List contextDataList = new LinkedList(); - for (SchemaPayload schemaPayload : context) { - contextDataList.add(schemaPayload.getMap()); - } - - envelope.setData(contextDataList); - payload.addMap(envelope.getMap(), this.base64Encoded, Parameter.CONTEXT_ENCODED, - Parameter.CONTEXT); - } - - if (this.subject != null) payload.addMap(new HashMap(subject.getSubject())); - - return payload; - } - - protected void setTrackerVersion(String version) { - this.trackerVersion = version; - } - - private void addTrackerPayload(Payload payload) { - this.emitter.addToBuffer(payload); - } - - private void setSubject(Subject subject) { - this.subject = subject; - } - - /** - * Sets the JSON schema to be used mainly for self-describing JSON. - * @param vendor Schema vendor - * @param schemaTag Schema tag type - * @param version Schema version tag - */ - public void setSchema(String vendor, String schemaTag, String version) { - this.contextSchema = vendor + "/contexts/" + schemaTag + "/" + version; - this.unstructSchema = vendor + "/unstruct_event/" + schemaTag + "/" + version; - this.baseSchemaPath = vendor; - this.schemaTag = schemaTag; - this.schemaVersion = version; - } - - /** - * @param pageUrl URL of the viewed page - * @param pageTitle Title of the viewed page - * @param referrer Referrer of the page - */ - public void trackPageView(String pageUrl, String pageTitle, String referrer) { - trackPageView(pageUrl, pageTitle, referrer, null, 0); - } - - /** - * @param pageUrl URL of the viewed page - * @param pageTitle Title of the viewed page - * @param referrer Referrer of the page - * @param context Custom context for the event - */ - public void trackPageView(String pageUrl, String pageTitle, String referrer, - List context) { - trackPageView(pageUrl,pageTitle, referrer, context, 0); - } - - /** - * @param pageUrl URL of the viewed page - * @param pageTitle Title of the viewed page - * @param referrer Referrer of the page - * @param timestamp Optional user-provided timestamp for the event - */ - public void trackPageView(String pageUrl, String pageTitle, String referrer, - long timestamp) { - trackPageView(pageUrl, pageTitle, referrer, null, timestamp); - } - - /** - * @param pageUrl URL of the viewed page - * @param pageTitle Title of the viewed page - * @param referrer Referrer of the page - * @param context Custom context for the event - * @param timestamp Optional user-provided timestamp for the event - */ - public void trackPageView(String pageUrl, String pageTitle, String referrer, - List context, long timestamp) { - // Precondition checks - Preconditions.checkNotNull(pageUrl); - Preconditions.checkArgument(!pageUrl.isEmpty(), "pageUrl cannot be empty"); - Preconditions.checkArgument(!pageTitle.isEmpty(), "pageTitle cannot be empty"); - Preconditions.checkArgument(!referrer.isEmpty(), "referrer cannot be empty"); - - Payload payload = new TrackerPayload(); - payload.add(Parameter.EVENT, Constants.EVENT_PAGE_VIEW); - payload.add(Parameter.PAGE_URL, pageUrl); - payload.add(Parameter.PAGE_TITLE, pageTitle); - payload.add(Parameter.PAGE_REFR, referrer); - - completePayload(payload, context, timestamp); - - addTrackerPayload(payload); - } - - /** - * @param category Category of the event - * @param action The event itself - * @param label Refer to the object the action is performed on - * @param property Property associated with either the action or the object - * @param value A value associated with the user action - */ - public void trackStructuredEvent(String category, String action, String label, String property, - int value) { - trackStructuredEvent(category, action, label, property, value, null, 0); - } - - /** - * @param category Category of the event - * @param action The event itself - * @param label Refer to the object the action is performed on - * @param property Property associated with either the action or the object - * @param value A value associated with the user action - * @param context Custom context for the event - */ - public void trackStructuredEvent(String category, String action, String label, String property, - int value, List context) { - trackStructuredEvent(category, action, label, property, value, context, 0); - } - - /** - * @param category Category of the event - * @param action The event itself - * @param label Refer to the object the action is performed on - * @param property Property associated with either the action or the object - * @param value A value associated with the user action - * @param timestamp Optional user-provided timestamp for the event - */ - public void trackStructuredEvent(String category, String action, String label, String property, - int value, long timestamp) { - trackStructuredEvent(category, action, label, property, value, null, timestamp); - } - - /** - * @param category Category of the event - * @param action The event itself - * @param label Refer to the object the action is performed on - * @param property Property associated with either the action or the object - * @param value A value associated with the user action - * @param context Custom context for the event - * @param timestamp Optional user-provided timestamp for the event - */ - public void trackStructuredEvent(String category, String action, String label, String property, - int value, List context, long timestamp) { - // Precondition checks - Preconditions.checkNotNull(label); - Preconditions.checkNotNull(property); - Preconditions.checkArgument(!label.isEmpty(), "label cannot be empty"); - Preconditions.checkArgument(!property.isEmpty(), "property cannot be empty"); - Preconditions.checkArgument(!category.isEmpty(), "category cannot be empty"); - Preconditions.checkArgument(!action.isEmpty(), "action cannot be empty"); - - Payload payload = new TrackerPayload(); - payload.add(Parameter.EVENT, Constants.EVENT_STRUCTURED); - payload.add(Parameter.SE_CATEGORY, category); - payload.add(Parameter.SE_ACTION, action); - payload.add(Parameter.SE_LABEL, label); - payload.add(Parameter.SE_PROPERTY, property); - payload.add(Parameter.SE_VALUE, Double.toString(value)); - - completePayload(payload, context, timestamp); - - addTrackerPayload(payload); - } - - /** - * - * @param eventData The properties of the event. Has two field: - A "data" field containing the event properties and - A "schema" field identifying the schema against which the data is validated - */ - public void trackUnstructuredEvent(Map eventData) { - trackUnstructuredEvent(eventData, null, 0); - } - - /** - * - * @param eventData The properties of the event. Has two field: - * A "data" field containing the event properties and - * A "schema" field identifying the schema against which the data is validated - * @param context Custom context for the event - */ - public void trackUnstructuredEvent(Map eventData, List context) { - trackUnstructuredEvent(eventData, context, 0); - } - - /** - * - * @param eventData The properties of the event. Has two field: - * A "data" field containing the event properties and - * A "schema" field identifying the schema against which the data is validated - * @param timestamp Optional user-provided timestamp for the event - */ - public void trackUnstructuredEvent(Map eventData, long timestamp) { - trackUnstructuredEvent(eventData, null, timestamp); - } - - /** - * - * @param eventData The properties of the event. Has two field: - * A "data" field containing the event properties and - * A "schema" field identifying the schema against which the data is validated - * @param context Custom context for the event - * @param timestamp Optional user-provided timestamp for the event - */ - public void trackUnstructuredEvent(Map eventData, List context, - long timestamp) { - Payload payload = new TrackerPayload(); - SchemaPayload envelope = new SchemaPayload(); - - envelope.setSchema(unstructSchema); - envelope.setData(eventData); - - payload.add(Parameter.EVENT, Constants.EVENT_UNSTRUCTURED); - payload.addMap(envelope.getMap(), base64Encoded, - Parameter.UNSTRUCTURED_ENCODED, Parameter.UNSTRUCTURED); - - completePayload(payload, context, timestamp); - - addTrackerPayload(payload); - } - - /** - * This is an internal method called by track_ecommerce_transaction. It is not for public use. - * @param order_id Order ID - * @param sku Item SKU - * @param price Item price - * @param quantity Item quantity - * @param name Item name - * @param category Item category - * @param currency The currency the price is expressed in - * @param context Custom context for the event - * @param timestamp Optional user-provided timestamp for the event - */ - protected void trackEcommerceTransactionItem(String order_id, String sku, Double price, - Integer quantity, String name, String category, - String currency, List context, - long timestamp) { - // Precondition checks - Preconditions.checkNotNull(name); - Preconditions.checkNotNull(category); - Preconditions.checkNotNull(currency); - Preconditions.checkArgument(!order_id.isEmpty(), "order_id cannot be empty"); - Preconditions.checkArgument(!sku.isEmpty(), "sku cannot be empty"); - Preconditions.checkArgument(!name.isEmpty(), "name cannot be empty"); - Preconditions.checkArgument(!category.isEmpty(), "category cannot be empty"); - Preconditions.checkArgument(!currency.isEmpty(), "currency cannot be empty"); - - Payload payload = new TrackerPayload(); - payload.add(Parameter.EVENT, Constants.EVENT_ECOMM_ITEM); - payload.add(Parameter.TI_ITEM_ID, order_id); - payload.add(Parameter.TI_ITEM_SKU, sku); - payload.add(Parameter.TI_ITEM_NAME, name); - payload.add(Parameter.TI_ITEM_CATEGORY, category); - payload.add(Parameter.TI_ITEM_PRICE, Double.toString(price)); - payload.add(Parameter.TI_ITEM_QUANTITY, Double.toString(quantity)); - payload.add(Parameter.TI_ITEM_CURRENCY, currency); - - completePayload(payload, context, timestamp); - - addTrackerPayload(payload); - } - - /** - * @param order_id ID of the eCommerce transaction - * @param total_value Total transaction value - * @param affiliation Transaction affiliation - * @param tax_value Transaction tax value - * @param shipping Delivery cost charged - * @param city Delivery address city - * @param state Delivery address state - * @param country Delivery address country - * @param currency The currency the price is expressed in - * @param items The items in the transaction - */ - public void trackEcommerceTransaction(String order_id, Double total_value, String affiliation, - Double tax_value, Double shipping, String city, - String state, String country, String currency, - List items) { - trackEcommerceTransaction(order_id, total_value, affiliation, tax_value, shipping, city, - state, country, currency, items, null, 0); - } - - /** - * @param order_id ID of the eCommerce transaction - * @param total_value Total transaction value - * @param affiliation Transaction affiliation - * @param tax_value Transaction tax value - * @param shipping Delivery cost charged - * @param city Delivery address city - * @param state Delivery address state - * @param country Delivery address country - * @param currency The currency the price is expressed in - * @param items The items in the transaction - * @param context Custom context for the event - */ - public void trackEcommerceTransaction(String order_id, Double total_value, String affiliation, - Double tax_value, Double shipping, String city, - String state, String country, String currency, - List items, List context) { - trackEcommerceTransaction(order_id, total_value, affiliation, tax_value, shipping, city, - state, country, currency, items, context, 0); - } - - /** - * @param order_id ID of the eCommerce transaction - * @param total_value Total transaction value - * @param affiliation Transaction affiliation - * @param tax_value Transaction tax value - * @param shipping Delivery cost charged - * @param city Delivery address city - * @param state Delivery address state - * @param country Delivery address country - * @param currency The currency the price is expressed in - * @param items The items in the transaction - * @param timestamp Optional user-provided timestamp for the event - */ - public void trackEcommerceTransaction(String order_id, Double total_value, String affiliation, - Double tax_value, Double shipping, String city, - String state, String country, String currency, - List items, long timestamp) { - trackEcommerceTransaction(order_id, total_value, affiliation, tax_value, shipping, city, - state, country, currency, items, null, timestamp); - } - - /** - * @param order_id ID of the eCommerce transaction - * @param total_value Total transaction value - * @param affiliation Transaction affiliation - * @param tax_value Transaction tax value - * @param shipping Delivery cost charged - * @param city Delivery address city - * @param state Delivery address state - * @param country Delivery address country - * @param currency The currency the price is expressed in - * @param items The items in the transaction - * @param context Custom context for the event - * @param timestamp Optional user-provided timestamp for the event - */ - @SuppressWarnings("unchecked") - public void trackEcommerceTransaction(String order_id, Double total_value, String affiliation, - Double tax_value, Double shipping, String city, - String state, String country, String currency, - List items, List context, - long timestamp) { - // Precondition checks - Preconditions.checkNotNull(affiliation); - Preconditions.checkNotNull(city); - Preconditions.checkNotNull(state); - Preconditions.checkNotNull(country); - Preconditions.checkNotNull(currency); - Preconditions.checkArgument(!order_id.isEmpty(), "order_id cannot be empty"); - Preconditions.checkArgument(!affiliation.isEmpty(), "affiliation cannot be empty"); - Preconditions.checkArgument(!city.isEmpty(), "city cannot be empty"); - Preconditions.checkArgument(!state.isEmpty(), "state cannot be empty"); - Preconditions.checkArgument(!country.isEmpty(), "country cannot be empty"); - Preconditions.checkArgument(!currency.isEmpty(), "currency cannot be empty"); - - Payload payload = new TrackerPayload(); - payload.add(Parameter.EVENT, Constants.EVENT_ECOMM); - payload.add(Parameter.TR_ID, order_id); - payload.add(Parameter.TR_TOTAL, Double.toString(total_value)); - payload.add(Parameter.TR_AFFILIATION, affiliation); - payload.add(Parameter.TR_TAX, Double.toString(tax_value)); - payload.add(Parameter.TR_SHIPPING, Double.toString(shipping)); - payload.add(Parameter.TR_CITY, city); - payload.add(Parameter.TR_STATE, state); - payload.add(Parameter.TR_COUNTRY, country); - payload.add(Parameter.TR_CURRENCY, currency); - - completePayload(payload, context, timestamp); - - for (TransactionItem item : items) { - trackEcommerceTransactionItem( - (String) item.get(Parameter.TI_ITEM_ID), - (String) item.get(Parameter.TI_ITEM_SKU), - (Double) item.get(Parameter.TI_ITEM_PRICE), - (Integer) item.get(Parameter.TI_ITEM_QUANTITY), - (String) item.get(Parameter.TI_ITEM_NAME), - (String) item.get(Parameter.TI_ITEM_CATEGORY), - (String) item.get(Parameter.TI_ITEM_CURRENCY), - (List) item.get(Parameter.CONTEXT), - timestamp); - } - - addTrackerPayload(payload); - } - - /** - * @param name The name of the screen view event - * @param id Screen view ID - */ - public void trackScreenView(String name, String id) { - trackScreenView(name, id, null, 0); - } - - /** - * @param name The name of the screen view event - * @param id Screen view ID - * @param context Custom context for the event - */ - public void trackScreenView(String name, String id, List context) { - trackScreenView(name, id, context, 0); - } - - /** - * @param name The name of the screen view event - * @param id Screen view ID - * @param timestamp Optional user-provided timestamp for the event - */ - public void trackScreenView(String name, String id, long timestamp) { - trackScreenView(name, id, null, timestamp); - } - - /** - * @param name The name of the screen view event - * @param id Screen view ID - * @param context Custom context for the event - * @param timestamp Optional user-provided timestamp for the event - */ - public void trackScreenView(String name, String id, List context, - long timestamp) { - Preconditions.checkArgument(name != null || id != null); - TrackerPayload trackerPayload = new TrackerPayload(); - - trackerPayload.add(Parameter.SV_NAME, name); - trackerPayload.add(Parameter.SV_ID, id); - - SchemaPayload payload = new SchemaPayload(); - - payload.setSchema( this.baseSchemaPath + "/contexts/" + - this.schemaTag + "/" + this.schemaVersion); - payload.setData(trackerPayload); - - trackUnstructuredEvent(payload.getMap(), context, timestamp); - } -} diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/TransactionItem.java b/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/TransactionItem.java deleted file mode 100644 index e4d756ad..00000000 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/TransactionItem.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ - -package com.snowplowanalytics.snowplow.tracker.core; - -import com.snowplowanalytics.snowplow.tracker.core.payload.SchemaPayload; - -import java.util.HashMap; -import java.util.List; - -public class TransactionItem extends HashMap { - - public TransactionItem (String order_id, String sku, double price, int quantity, String name, - String category, String currency) { - this(order_id,sku, price, quantity, name, category, currency, null); - } - - public TransactionItem (String order_id, String sku, double price, int quantity, String name, - String category, String currency, List context) { - put(Parameter.EVENT, "ti"); - put(Parameter.TI_ITEM_ID, order_id); - put(Parameter.TI_ITEM_SKU, sku); - put(Parameter.TI_ITEM_NAME, name); - put(Parameter.TI_ITEM_CATEGORY, category); - put(Parameter.TI_ITEM_PRICE, price); - put(Parameter.TI_ITEM_QUANTITY, quantity); - put(Parameter.TI_ITEM_CURRENCY, currency); - - put(Parameter.CONTEXT, context); - - put(Parameter.TIMESTAMP, Util.getTimestamp()); - } - - @SuppressWarnings({"unchecked", "ConstantConditions"}) - @Override - public Object put(Object key, Object value) { - if (value != null || value != "") return super.put(key, value); - else - return null; - } -} diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Util.java b/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Util.java deleted file mode 100644 index 26f88d06..00000000 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Util.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ - -package com.snowplowanalytics.snowplow.tracker.core; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.util.Map; -import java.util.Random; -import java.util.UUID; - -public class Util { - private static final Logger logger = LoggerFactory.getLogger(Util.class); - private static ObjectMapper sObjectMapper = new ObjectMapper(); - public static ObjectMapper defaultMapper() { - return sObjectMapper; - } - - @Deprecated - public static JsonNode stringToJsonNode(String str) { - try { - return defaultMapper().readTree(str); - } catch (IOException e) { - e.printStackTrace(); - } - return null; - } - - public static JsonNode mapToJsonNode(Map map) { - return defaultMapper().valueToTree(map); - } - - public static int getTransactionId() { - Random r = new Random(); //NEED ID RANGE - return r.nextInt(999999-100000+1) + 100000; - } - - public static String getTimestamp() { - return Long.toString(System.currentTimeMillis()); - } - - - /* Addition functions - * Used to add different sources of key=>value pairs to a map. - * Map is then used to build "Associative array for getter function. - * Some use Base64 encoding - */ - public static String base64Encode(String string) { - try { - return Base64.encode(string.getBytes()); - } catch (UnsupportedEncodingException e) { - e.printStackTrace(); - } - return null; - } - - public static String getEventId() { - return UUID.randomUUID().toString(); - } -} diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Version.java b/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Version.java deleted file mode 100644 index 3f1b0772..00000000 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Version.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ - -package com.snowplowanalytics.snowplow.tracker.core; - -public class Version { - static final String TRACKER = "java-core-0.1.4"; - static final String VERSION = "0.1.4"; -} diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/emitter/BufferOption.java b/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/emitter/BufferOption.java deleted file mode 100644 index bcbcdcea..00000000 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/emitter/BufferOption.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ - -package com.snowplowanalytics.snowplow.tracker.core.emitter; - -/** - * BufferOption is used to set the buffer size of your Emitter. - */ -public enum BufferOption { - /** - * Sends events immediately when being tracked. This may cause a lot of network traffic - * depending on it's usage. - */ - Instant(1), - - /** - * Sends events in a group only after collecting 10 events. In a POST request, this is - * sent in one payload. For a GET request, individual requests are sent following each other. - */ - Default(10); - - private int code; - - private BufferOption(int c) { - code = c; - } - - public int getCode() { - return code; - } -} diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/emitter/Emitter.java b/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/emitter/Emitter.java deleted file mode 100644 index 2d43cbd7..00000000 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/emitter/Emitter.java +++ /dev/null @@ -1,267 +0,0 @@ -/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ - -package com.snowplowanalytics.snowplow.tracker.core.emitter; - -import com.snowplowanalytics.snowplow.tracker.core.Constants; -import com.snowplowanalytics.snowplow.tracker.core.payload.Payload; -import com.snowplowanalytics.snowplow.tracker.core.payload.SchemaPayload; - -import org.apache.http.HttpResponse; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.client.utils.URIBuilder; -import org.apache.http.entity.StringEntity; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.impl.nio.client.CloseableHttpAsyncClient; -import org.apache.http.impl.nio.client.HttpAsyncClients; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.Map; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; - -public class Emitter { - - private URIBuilder uri; - private RequestMethod requestMethod = RequestMethod.Synchronous; - private CloseableHttpClient httpClient; - private CloseableHttpAsyncClient httpAsyncClient; - private final ArrayList buffer = new ArrayList(); - - private final Logger logger = LoggerFactory.getLogger(Emitter.class); - - protected BufferOption option = BufferOption.Default; - protected RequestCallback requestCallback; - protected HttpMethod httpMethod = HttpMethod.GET; - - /** - * Default constructor does nothing. - */ - public Emitter() { - - } - - /** - * Create an Emitter instance with a collector URL. - * @param URI The collector URL. Don't include "http://" - this is done automatically. - */ - public Emitter(String URI) { - this(URI, HttpMethod.GET, null); - } - - /** - * Create an Emitter instance with a collector URL, and callback function. - * @param URI The collector URL. Don't include "http://" - this is done automatically. - * @param callback The callback function to handle success/failure cases when sending events. - */ - public Emitter(String URI, RequestCallback callback) { - this(URI, HttpMethod.GET, callback); - } - - /** - * Create an Emitter instance with a collector URL, - * @param URI The collector URL. Don't include "http://" - this is done automatically. - * @param httpMethod The HTTP request method. If GET, BufferOption is set to Instant. - */ - public Emitter(String URI, HttpMethod httpMethod) { - this(URI, httpMethod, null); - } - - /** - * Create an Emitter instance with a collector URL and HttpMethod to send requests. - * @param URI The collector URL. Don't include "http://" - this is done automatically. - * @param httpMethod The HTTP request method. If GET, BufferOption is set to Instant. - * @param callback The callback function to handle success/failure cases when sending events. - */ - public Emitter(String URI, HttpMethod httpMethod, RequestCallback callback) { - if (httpMethod == HttpMethod.GET) { - uri = new URIBuilder() - .setScheme("http") - .setHost(URI) - .setPath("/i"); - } else { // POST - uri = new URIBuilder() - .setScheme("http") - .setHost(URI) - .setPath("/" + Constants.DEFAULT_VENDOR + "/tp2"); - } - this.requestCallback = callback; - this.httpMethod = httpMethod; - this.httpClient = HttpClients.createDefault(); - - if (httpMethod == HttpMethod.GET) { - this.setBufferOption(BufferOption.Instant); - } - - } - - /** - * Sets whether the buffer should send events instantly or after the buffer has reached - * it's limit. By default, this is set to BufferOption Default. - * @param option Set the BufferOption enum to Instant send events upon creation. - */ - public void setBufferOption(BufferOption option) { - this.option = option; - } - - /** - * Sets whether requests should be sent synchronously or asynchronously. - * @param option The HTTP request method - */ - public void setRequestMethod(RequestMethod option) { - this.requestMethod = option; - this.httpAsyncClient = HttpAsyncClients.createDefault(); - this.httpAsyncClient.start(); - } - - /** - * Add event payloads to the emitter's buffer - * @param payload Payload to be added - * @return Returns the boolean value if the event was successfully added to the buffer - */ - public boolean addToBuffer(Payload payload) { - boolean ret = buffer.add(payload); - if (buffer.size() == option.getCode()) - flushBuffer(); - return ret; - } - - /** - * Sends all events in the buffer to the collector. - */ - public void flushBuffer() { - if (buffer.isEmpty()) { - logger.debug("Buffer is empty, exiting flush operation.."); - return; - } - - if (httpMethod == HttpMethod.GET) { - int success_count = 0; - LinkedList unsentPayloads = new LinkedList(); - - for (Payload payload : buffer) { - int status_code = sendGetData(payload).getStatusLine().getStatusCode(); - if (status_code == 200) - success_count++; - else - unsentPayloads.add(payload); - } - - if (unsentPayloads.size() == 0) { - if (requestCallback != null) - requestCallback.onSuccess(success_count); - } - else if (requestCallback != null) - requestCallback.onFailure(success_count, unsentPayloads); - - } else if (httpMethod == HttpMethod.POST) { - LinkedList unsentPayload = new LinkedList(); - - SchemaPayload postPayload = new SchemaPayload(); - postPayload.setSchema(Constants.SCHEMA_PAYLOAD_DATA); - - ArrayList eventMaps = new ArrayList(); - for (Payload payload : buffer) { - eventMaps.add(payload.getMap()); - } - postPayload.setData(eventMaps); - - int status_code = sendPostData(postPayload).getStatusLine().getStatusCode(); - if (status_code == 200 && requestCallback != null) - requestCallback.onSuccess(buffer.size()); - else if (requestCallback != null){ - unsentPayload.add(postPayload); - requestCallback.onFailure(0, unsentPayload); - } - } - - // Empties current buffer - buffer.clear(); - } - - protected HttpResponse sendPostData(Payload payload) { - HttpPost httpPost = new HttpPost(uri.toString()); - httpPost.addHeader("Content-Type", "application/json; charset=utf-8"); - HttpResponse httpResponse = null; - - try { - StringEntity params = new StringEntity(payload.toString()); - httpPost.setEntity(params); - if (requestMethod == RequestMethod.Asynchronous) { - Future future = httpAsyncClient.execute(httpPost, null); - httpResponse = future.get(); - } else { - httpResponse = httpClient.execute(httpPost); - } - logger.debug(httpResponse.getStatusLine().toString()); - } catch (UnsupportedEncodingException e) { - logger.error("Encoding exception with the payload."); - e.printStackTrace(); - } catch (IOException e) { - logger.error("Error when sending HTTP POST."); - e.printStackTrace(); - } catch (InterruptedException e) { - logger.error("Interruption error when sending HTTP POST request."); - e.printStackTrace(); - } catch (ExecutionException e) { - e.printStackTrace(); - } - return httpResponse; - } - - @SuppressWarnings("unchecked") - protected HttpResponse sendGetData(Payload payload) { - HashMap hashMap = (HashMap) payload.getMap(); - Iterator iterator = hashMap.keySet().iterator(); - HttpResponse httpResponse = null; - - while (iterator.hasNext()) { - String key = iterator.next(); - String value = (String) hashMap.get(key); - uri.setParameter(key, value); - } - - try { - HttpGet httpGet = new HttpGet(uri.build()); - if (requestMethod == RequestMethod.Asynchronous) { - Future future = httpAsyncClient.execute(httpGet, null); - httpResponse = future.get(); - } else { - httpResponse = httpClient.execute(httpGet); - } - logger.debug(httpResponse.getStatusLine().toString()); - } catch (IOException e) { - logger.error("Error when sending HTTP GET error."); - e.printStackTrace(); - } catch (URISyntaxException e) { - logger.error("Error when creating HTTP GET request. Probably parsing error.."); - e.printStackTrace(); - } catch (InterruptedException e) { - logger.error("Interruption error when sending HTTP GET request."); - e.printStackTrace(); - } catch (ExecutionException e) { - e.printStackTrace(); - } - return httpResponse; - } -} diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/payload/SchemaPayload.java b/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/payload/SchemaPayload.java deleted file mode 100644 index 2dd085fb..00000000 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/payload/SchemaPayload.java +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ - -package com.snowplowanalytics.snowplow.tracker.core.payload; - -import com.fasterxml.jackson.core.JsonParseException; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.google.common.base.Preconditions; -import com.snowplowanalytics.snowplow.tracker.core.Parameter; -import com.snowplowanalytics.snowplow.tracker.core.Util; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -public class SchemaPayload implements Payload { - - private final ObjectMapper objectMapper = Util.defaultMapper(); - private final Logger logger = LoggerFactory.getLogger(SchemaPayload.class); - private ObjectNode objectNode = objectMapper.createObjectNode(); - - public SchemaPayload() { } - - public SchemaPayload(Payload payload) { - ObjectNode data; - - if (payload.getClass() == TrackerPayload.class) { - logger.debug("Payload class is a TrackerPayload instance."); - logger.debug("Trying getNode()"); - data = (ObjectNode) payload.getNode(); - } else { - logger.debug("Converting Payload map to ObjectNode."); - data = objectMapper.valueToTree(payload.getMap()); - } - objectNode.set(Parameter.DATA, data); - } - - public SchemaPayload setSchema(String schema) { - Preconditions.checkNotNull(schema, "schema cannot be null"); - Preconditions.checkArgument(!schema.isEmpty(), "schema cannot be empty."); - - logger.debug("Setting schema: {}", schema); - objectNode.put(Parameter.SCHEMA, schema); - return this; - } - - public SchemaPayload setData(Payload data) { - try { - objectNode.putPOJO(Parameter.DATA, objectMapper.writeValueAsString(data.getMap())); - } catch (JsonProcessingException e) { - e.printStackTrace(); - } - return this; - } - - public SchemaPayload setData(Object data) { - try { - objectNode.putPOJO(Parameter.DATA, objectMapper.writeValueAsString(data)); - } catch (JsonProcessingException e) { - e.printStackTrace(); - } - return this; - } - - @Override - public void add(String key, String value) { - /* - * We intentionally do nothing because we do not want our SchemaPayload - * to do anything except accept a 'data' and 'schema' - */ - logger.debug("add(String, String) method called: Doing nothing."); - } - - @Override - public void add(String key, Object value) { - /* - * We intentionally do nothing because we do not want our SchemaPayload - * to do anything except accept a 'data' and 'schema' - */ - logger.debug("add(String, Object) method called: Doing nothing."); - } - - @Override - public void addMap(Map map) { - /* - * We intentionally do nothing because we do not want our SchemaPayload - * to do anything except accept a 'data' and 'schema' - */ - logger.debug("addMap(Map) method called: Doing nothing."); - } - - @Override - public void addMap(Map map, Boolean base64_encoded, String type_encoded, - String type_no_encoded) { - /* - * We intentionally do nothing because we do not want our SchemaPayload - * to do anything except accept a 'data' and 'schema' - */ - logger.debug("addMap(Map, Boolean, String, String) method called: Doing nothing."); - } - - public Map getMap() { - HashMap map = new HashMap(); - try { - map = objectMapper.readValue(objectNode.toString(), - new TypeReference(){}); - } catch (JsonMappingException e) { - e.printStackTrace(); - } catch (JsonParseException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } - return map; - } - - public JsonNode getNode() { return objectNode; } - - public String toString() { return objectNode.toString(); } -} diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/payload/TrackerPayload.java b/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/payload/TrackerPayload.java deleted file mode 100644 index 4a3cb354..00000000 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/payload/TrackerPayload.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ - -package com.snowplowanalytics.snowplow.tracker.core.payload; - -import com.fasterxml.jackson.core.JsonParseException; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.snowplowanalytics.snowplow.tracker.core.Util; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; - -public class TrackerPayload implements Payload { - - private final ObjectMapper objectMapper = Util.defaultMapper(); - private final Logger logger = LoggerFactory.getLogger(TrackerPayload.class); - private ObjectNode objectNode = objectMapper.createObjectNode(); - - @Override - public void add(String key, String value) { - if (value == null || value.isEmpty()) { - logger.debug("kv-value is empty. Returning out without adding key.."); - return; - } - - logger.debug("Adding new key: {} with value: {}", key, value); - objectNode.put(key, value); - } - - @Override - public void add(String key, Object value) { - if (value == null) { - logger.debug("kv-value is empty. Returning out without adding key.."); - return; - } - - logger.debug("Adding new key: {} with object value: {}", key, value); - try { - objectNode.putPOJO(key, objectMapper.writeValueAsString(value)); - } catch (JsonProcessingException e) { - e.printStackTrace(); - } - } - - @Override - public void addMap(Map map) { - // Return if we don't have a map - if (map == null) { - logger.debug("Map passed in is null. Returning without adding map.."); - return; - } - - Set keys = map.keySet(); - for(String key : keys) { - add(key, map.get(key)); - } - } - - @Override - public void addMap(Map map, Boolean base64_encoded, String type_encoded, String type_no_encoded) { - // Return if we don't have a map - if (map == null) { - logger.debug("Map passed in is null. Returning nothing.."); - return; - } - - String mapString; - try { - mapString = objectMapper.writeValueAsString(map); - } catch (JsonProcessingException e) { - e.printStackTrace(); - return; // Return because we can't continue - } - - if (base64_encoded) { // base64 encoded data - objectNode.put(type_encoded, Util.base64Encode(mapString)); - } else { // add it as a child node - add(type_no_encoded, mapString); - } - } - - public JsonNode getNode() { - return objectNode; - } - - @Override - public Map getMap() { - HashMap map = new HashMap(); - try { - logger.debug("Attempting to create a Map structure from ObjectNode."); - map = objectMapper.readValue(objectNode.toString(), new TypeReference(){}); - } catch (JsonMappingException e) { - e.printStackTrace(); - } catch (JsonParseException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } - return map; - } - - @Override - public String toString() { - return objectNode.toString(); - } -} diff --git a/snowplow-java-tracker-core/src/test/java/com/snowplowanalytics/snowplow/tracker/EmitterTest.java b/snowplow-java-tracker-core/src/test/java/com/snowplowanalytics/snowplow/tracker/EmitterTest.java deleted file mode 100644 index de9d2f78..00000000 --- a/snowplow-java-tracker-core/src/test/java/com/snowplowanalytics/snowplow/tracker/EmitterTest.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.snowplowanalytics.snowplow.tracker; - -import com.snowplowanalytics.snowplow.tracker.core.emitter.BufferOption; -import com.snowplowanalytics.snowplow.tracker.core.emitter.Emitter; -import com.snowplowanalytics.snowplow.tracker.core.emitter.HttpMethod; -import com.snowplowanalytics.snowplow.tracker.core.emitter.RequestCallback; -import com.snowplowanalytics.snowplow.tracker.core.emitter.RequestMethod; -import com.snowplowanalytics.snowplow.tracker.core.payload.Payload; -import com.snowplowanalytics.snowplow.tracker.core.payload.TrackerPayload; - -import junit.framework.TestCase; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; - -public class EmitterTest extends TestCase { - -// private static String testURL = "segfault.ngrok.com"; - private static String testURL = "d3rkrsqld9gmqf.cloudfront.net"; - - @Test - public void testEmitterConstructor() throws Exception { - Emitter emitter = new Emitter(testURL, HttpMethod.POST); - } - - @Test - public void testEmitterConstructor2() throws Exception { - Emitter emitter = new Emitter(testURL); - } - - @Test - public void testFlushGet() throws Exception { - Emitter emitter = new Emitter(testURL); - - TrackerPayload payload; - LinkedHashMap foo = new LinkedHashMap(); - foo.put("test", "testFlushBuffer"); - payload = new TrackerPayload(); - payload.addMap(foo); - - emitter.addToBuffer(payload); - - emitter.flushBuffer(); - } - - @Test - public void testFlushPost() throws Exception { - Emitter emitter = new Emitter(testURL, HttpMethod.POST, null); - - TrackerPayload payload; - LinkedHashMap foo = new LinkedHashMap(); - ArrayList bar = new ArrayList(); - bar.add("somebar"); - bar.add("somebar"); - foo.put("test", "testMaxBuffer"); - foo.put("mehh", bar); - payload = new TrackerPayload(); - payload.addMap(foo); - - emitter.addToBuffer(payload); - - - emitter.flushBuffer(); - } - - @Test - public void testBufferOption() throws Exception { - Emitter emitter = new Emitter(testURL); - emitter.setBufferOption(BufferOption.Instant); - } - - @Test - public void testFlushBuffer() throws Exception { - - Emitter emitter = new Emitter(testURL, HttpMethod.GET, new RequestCallback() { - @Override - public void onSuccess(int successCount) { - System.out.println("Buffer length for POST/GET:" + successCount); - } - - @Override - public void onFailure(int successCount, List failedEvent) { - System.out.println("Failure, successCount: " + successCount + - "\nfailedEvent:\n" + failedEvent.toString()); - } - }); - - emitter.setRequestMethod(RequestMethod.Asynchronous); - for (int i=0; i < 5; i++) { - TrackerPayload payload; - LinkedHashMap foo = new LinkedHashMap(); - foo.put("test", "testFlushBuffer"); - payload = new TrackerPayload(); - payload.addMap(foo); - - emitter.addToBuffer(payload); - } - emitter.flushBuffer(); - } - - @Test - public void testMaxBuffer() throws Exception { - Emitter emitter = new Emitter(testURL, HttpMethod.GET, null); - emitter.setRequestMethod(RequestMethod.Asynchronous); - for (int i=0; i < 10; i++) { - TrackerPayload payload; - LinkedHashMap foo = new LinkedHashMap(); - foo.put("test", "testFlushBuffer"); - payload = new TrackerPayload(); - payload.addMap(foo); - - emitter.addToBuffer(payload); - } - } -} \ No newline at end of file diff --git a/snowplow-java-tracker-core/src/test/java/com/snowplowanalytics/snowplow/tracker/SubjectTest.java b/snowplow-java-tracker-core/src/test/java/com/snowplowanalytics/snowplow/tracker/SubjectTest.java deleted file mode 100644 index 1c0c2597..00000000 --- a/snowplow-java-tracker-core/src/test/java/com/snowplowanalytics/snowplow/tracker/SubjectTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.snowplowanalytics.snowplow.tracker; - -import com.snowplowanalytics.snowplow.tracker.core.DevicePlatform; -import com.snowplowanalytics.snowplow.tracker.core.Subject; - -import junit.framework.TestCase; - -import org.junit.Test; - -import java.util.HashMap; -import java.util.Map; - -public class SubjectTest extends TestCase { - - @Test - public void testSetPlatform() throws Exception { - Subject subject = new Subject(); - subject.getSubject().get("p"); - assertEquals("pc", subject.getSubject().get("p")); - } - - @Test - public void testSetPlatform2() throws Exception { - Subject subject = new Subject(); - subject.setPlatform(DevicePlatform.ConnectedTV); - subject.getSubject().get("p"); - assertEquals("tv", subject.getSubject().get("p")); - } - - @Test - public void testSetUserId() throws Exception { - Subject subject = new Subject(); - subject.setUserId("user1"); - assertEquals("user1", subject.getSubject().get("uid")); - } - - @Test - public void testSetScreenResolution() throws Exception { - Subject subject = new Subject(); - subject.setScreenResolution(100, 150); - assertEquals("100x150", subject.getSubject().get("res")); - } - - @Test - public void testSetViewPort() throws Exception { - Subject subject = new Subject(); - subject.setViewPort(150, 100); - assertEquals("150x100", subject.getSubject().get("vp")); - - } - - @Test - public void testSetColorDepth() throws Exception { - Subject subject = new Subject(); - subject.setColorDepth(10); - assertEquals("10", subject.getSubject().get("cd")); - } - - // Enable only if running locally, change assert to your local timezone -// @Test -// public void testSetTimezone() throws Exception { -// Subject subject = new Subject(); -// assertEquals("America/Toronto", subject.getSubject().get("tz")); -// } - - @Test - public void testSetTimezone2() throws Exception { - Subject subject = new Subject(); - subject.setTimezone("America/Toronto"); - assertEquals("America/Toronto", subject.getSubject().get("tz")); - } - - @Test - public void testSetLanguage() throws Exception { - Subject subject = new Subject(); - subject.setLanguage("EN"); - assertEquals("EN", subject.getSubject().get("lang")); - } - - @Test - public void testGetSubject() throws Exception { - Subject subject = new Subject(); - Map expected = new HashMap(); - subject.setTimezone("America/Toronto"); - subject.setUserId("user1"); - - expected.put("tz", "America/Toronto"); - expected.put("p", "pc"); - expected.put("uid", "user1"); - - assertEquals(expected, subject.getSubject()); - } -} \ No newline at end of file diff --git a/snowplow-java-tracker-core/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerPayloadTest.java b/snowplow-java-tracker-core/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerPayloadTest.java deleted file mode 100644 index 98d6ccd9..00000000 --- a/snowplow-java-tracker-core/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerPayloadTest.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.snowplowanalytics.snowplow.tracker; - -import com.snowplowanalytics.snowplow.tracker.core.payload.Payload; -import com.snowplowanalytics.snowplow.tracker.core.payload.SchemaPayload; -import com.snowplowanalytics.snowplow.tracker.core.payload.TrackerPayload; - -import junit.framework.TestCase; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.Map; - -public class TrackerPayloadTest extends TestCase { - - @Test - public void testAddString() throws Exception { - Payload payload = new TrackerPayload(); - payload.add("foo", "bar"); - - String res = "{\"foo\":\"bar\"}"; - assertEquals(res, payload.toString()); - } - - @Test - public void testAddObject() throws Exception { - Payload payload = new TrackerPayload(); - Map map = new HashMap(); - map.put("foo", "bar"); - map.put("more foo", "more bar"); - payload.add("map", map); - - String res = "{\"map\":{\"more foo\":\"more bar\",\"foo\":\"bar\"}}"; - assertEquals(res, payload.toString()); - } - - @Test - public void testAddMap() throws Exception { - Map foo = new LinkedHashMap(); - ArrayList bar = new ArrayList(); - bar.add("somebar"); - bar.add("somebar2"); - foo.put("myKey", "my Value"); - foo.put("mehh", bar); - Payload payload = new TrackerPayload(); - payload.addMap(foo); - - String res = "{\"myKey\":\"my Value\",\"mehh\":[\"somebar\",\"somebar2\"]}"; - assertEquals(res, payload.toString()); - } - - @Test - public void testAddMapNotEncoding() throws Exception { - Map foo = new LinkedHashMap(); - ArrayList bar = new ArrayList(); - bar.add("somebar"); - bar.add("somebar2"); - foo.put("myKey", "my Value"); - foo.put("mehh", bar); - Payload payload = new TrackerPayload(); - payload.addMap(foo, false, "cx", "co"); - - String res = "{\"co\":\"{\\\"myKey\\\":\\\"my Value\\\",\\\"mehh\\\":[\\\"somebar\\\",\\\"somebar2\\\"]}\"}"; - assertEquals(res, payload.toString()); - } - - @Test - public void testAddMapEncoding() throws Exception { - Map foo = new LinkedHashMap(); - ArrayList bar = new ArrayList(); - bar.add("somebar"); - bar.add("somebar2"); - foo.put("myKey", "my Value"); - foo.put("mehh", bar); - Payload payload = new TrackerPayload(); - payload.addMap(foo, true, "cx", "co"); - - String res = "{\"cx\":\"eyJteUtleSI6Im15IFZhbHVlIiwibWVoaCI6WyJzb21lYmFyIiwic29tZWJhcjIiXX0=\"}"; - assertEquals(res, payload.toString()); - } - - @Test - public void testSetData() { - TrackerPayload payload; - String res; - LinkedHashMap foo = new LinkedHashMap(); - ArrayList bar = new ArrayList(); - bar.add("somebar"); - bar.add("somebar2"); - foo.put("myKey", "my Value"); - foo.put("mehh", bar); - String myarray[] = {"arrayItem","arrayItem2"}; - payload = new TrackerPayload(); - payload.add("myarray", myarray); - - res = "{\"myarray\":[\"arrayItem\",\"arrayItem2\"]}"; - assertEquals(res, payload.toString()); - - payload = new TrackerPayload(); - payload.add("foo", foo); - - res = "{\"foo\":{\"myKey\":\"my Value\",\"mehh\":[\"somebar\",\"somebar2\"]}}"; - assertEquals(res, payload.toString()); - - payload = new TrackerPayload(); - payload.add("bar", bar); - - res = "{\"bar\":[\"somebar\",\"somebar2\"]}"; - assertEquals(res, payload.toString()); - } - - @Test - public void testSetSchema() throws Exception { - SchemaPayload payload = new SchemaPayload(); - payload.setSchema("iglu:com.snowplowanalytics.snowplow/payload_data/jsonschema/1-0-0"); - String res = "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/payload_data/jsonschema/1-0-0\"}"; - assertEquals(res, payload.toString()); - } - - @Test - public void testGetMap() throws Exception { - SchemaPayload payload; - String res; - LinkedHashMap foo = new LinkedHashMap(); - ArrayList bar = new ArrayList(); - bar.add("somebar"); - bar.add("somebar2"); - foo.put("myKey", "my Value"); - foo.put("mehh", bar); - LinkedHashMap data = new LinkedHashMap(); - data.put("data", foo); - payload = new SchemaPayload(); - payload.setData(foo); - - assertEquals(data, payload.getMap()); - } -} \ No newline at end of file diff --git a/snowplow-java-tracker-core/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerTest.java b/snowplow-java-tracker-core/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerTest.java deleted file mode 100644 index d58f6c26..00000000 --- a/snowplow-java-tracker-core/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerTest.java +++ /dev/null @@ -1,185 +0,0 @@ -package com.snowplowanalytics.snowplow.tracker; - -import com.snowplowanalytics.snowplow.tracker.core.Subject; -import com.snowplowanalytics.snowplow.tracker.core.Tracker; -import com.snowplowanalytics.snowplow.tracker.core.TransactionItem; -import com.snowplowanalytics.snowplow.tracker.core.emitter.BufferOption; -import com.snowplowanalytics.snowplow.tracker.core.emitter.Emitter; -import com.snowplowanalytics.snowplow.tracker.core.emitter.HttpMethod; -import com.snowplowanalytics.snowplow.tracker.core.emitter.RequestMethod; -import com.snowplowanalytics.snowplow.tracker.core.payload.SchemaPayload; - -import junit.framework.TestCase; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.Map; - -public class TrackerTest extends TestCase { - -// private static String testURL = "segfault.ngrok.com"; - private static String testURL = "d3rkrsqld9gmqf.cloudfront.net"; - - @Test - public void testSetSchema() throws Exception { - - } - - @Test - public void testTrackPageView() throws Exception { - - } - - @Test - public void testTrackPageView1() throws Exception { - - } - - @Test - public void testTrackPageView2() throws Exception { - - } - - @Test - public void testTrackPageView3() throws Exception { - Emitter emitter = new Emitter(testURL, HttpMethod.POST); - Subject subject = new Subject(); - subject.setViewPort(320, 480); - Tracker tracker = new Tracker(emitter, subject, "AF003", "cloudfront", false); - emitter.setRequestMethod(RequestMethod.Asynchronous); - - SchemaPayload context = new SchemaPayload(); - Map someContext = new HashMap(); - someContext.put("someContextKey", "testTrackPageView3"); - context.setSchema("iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0"); - context.setData(someContext); - ArrayList contextList = new ArrayList(); - contextList.add(context); - - tracker.trackPageView("www.mypage.com", "My Page", "www.me.com", contextList); - - emitter.flushBuffer(); - } - - @Test - public void testTrackStructuredEvent() throws Exception { - - } - - @Test - public void testTrackStructuredEvent1() throws Exception { - - } - - @Test - public void testTrackStructuredEvent2() throws Exception { - - } - - @Test - public void testTrackStructuredEvent3() throws Exception { - - } - - @Test - public void testTrackUnstructuredEvent() throws Exception { - - } - - @Test - public void testTrackUnstructuredEvent1() throws Exception { - - } - - @Test - public void testTrackUnstructuredEvent2() throws Exception { - - } - - @Test - public void testTrackUnstructuredEvent3() throws Exception { - - } - - @Test - public void testTrackEcommerceTransactionItem() throws Exception { - - } - - @Test - public void testTrackEcommerceTransaction() throws Exception { - Emitter emitter = new Emitter(testURL, HttpMethod.POST); - Tracker tracker = new Tracker(emitter, "AF003", "cloudfront", false); - emitter.setRequestMethod(RequestMethod.Asynchronous); - - SchemaPayload context = new SchemaPayload(); - Map someContext = new HashMap(); - someContext.put("someContextKey", "testTrackPageView2"); - context.setSchema("iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0"); - context.setData(someContext); - ArrayList contextList = new ArrayList(); - contextList.add(context); - - TransactionItem transactionItem = new TransactionItem("order-8", "no_sku", - 34.0, 1, "Big Order", "Food", "USD", contextList); - LinkedList transactionItemLinkedList = new LinkedList(); - transactionItemLinkedList.add(transactionItem); - tracker.trackEcommerceTransaction("order-7", 25.0, "no_affiliate", 0.0, 0.0, "Dover", - "Delaware", "US", "USD", transactionItemLinkedList); - - emitter.flushBuffer(); - } - - @Test - public void testTrackEcommerceTransaction1() throws Exception { - - } - - @Test - public void testTrackEcommerceTransaction2() throws Exception { - - } - - @Test - public void testTrackEcommerceTransaction3() throws Exception { - - } - - @Test - public void testTrackScreenView() throws Exception { - Emitter emitter = new Emitter(testURL, HttpMethod.POST); - Subject subject = new Subject(); - subject.setViewPort(320, 480); - Tracker tracker = new Tracker(emitter, subject, "AF003", "cloudfront", false); - emitter.setRequestMethod(RequestMethod.Asynchronous); - emitter.setBufferOption(BufferOption.Instant); - - SchemaPayload context = new SchemaPayload(); - Map someContext = new HashMap(); - someContext.put("someContextKey", "testTrackPageView2"); - context.setSchema("iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0"); - context.setData(someContext); - ArrayList contextList = new ArrayList(); - contextList.add(context); - - tracker.trackScreenView(null, "screen_1", contextList, 0); - } - - @Test - public void testTrackScreenView1() throws Exception { - - } - - @Test - public void testTrackScreenView2() throws Exception { - - } - - @Test - public void testTrackScreenView3() throws Exception { - - } -} \ No newline at end of file diff --git a/snowplow-java-tracker-core/src/test/java/com/snowplowanalytics/snowplow/tracker/UtilTest.java b/snowplow-java-tracker-core/src/test/java/com/snowplowanalytics/snowplow/tracker/UtilTest.java deleted file mode 100644 index cee8bcb6..00000000 --- a/snowplow-java-tracker-core/src/test/java/com/snowplowanalytics/snowplow/tracker/UtilTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.snowplowanalytics.snowplow.tracker; - -import com.fasterxml.jackson.databind.JsonNode; -import com.snowplowanalytics.snowplow.tracker.core.Util; - -import junit.framework.TestCase; -import org.junit.Test; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Map; - -public class UtilTest extends TestCase { - @Test - public void testGetTimestamp() { - assertNotNull(Util.getTimestamp()); - } - - @Test - public void testGetTransactionId() { - assertNotNull(Util.getTransactionId()); - } - - @Test - public void testMapToJsonNode() { - Map map = new HashMap(); - map.put("foo", "bar"); - - JsonNode node = Util.mapToJsonNode(map); - - assertEquals("bar", node.get("foo").asText()); - } - - @Test - public void testMapToJsonNode2() { - Map map = new HashMap(); - map.put("foo", "bar"); - - ArrayList list = new ArrayList(); - list.add("some"); - list.add("stuff"); - - map.put("list", list); - - JsonNode node = Util.mapToJsonNode(map); - - assertEquals("{\"list\":[\"some\",\"stuff\"],\"foo\":\"bar\"}", node.toString()); - } -} diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/DevicePlatform.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/DevicePlatform.java similarity index 88% rename from snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/DevicePlatform.java rename to src/main/java/com/snowplowanalytics/snowplow/tracker/DevicePlatform.java index 4a85379c..f2a2460a 100644 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/DevicePlatform.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/DevicePlatform.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. * * This program is licensed to you under the Apache License Version 2.0, * and you may not use this file except in compliance with the Apache License Version 2.0. @@ -11,8 +11,11 @@ * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ -package com.snowplowanalytics.snowplow.tracker.core; +package com.snowplowanalytics.snowplow.tracker; +/** + * The supported platform options for Tracker objects. + */ public enum DevicePlatform { Web { public String toString() { diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/Snowplow.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/Snowplow.java new file mode 100644 index 00000000..53f930e9 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/Snowplow.java @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker; + +import com.snowplowanalytics.snowplow.tracker.configuration.EmitterConfiguration; +import com.snowplowanalytics.snowplow.tracker.configuration.NetworkConfiguration; +import com.snowplowanalytics.snowplow.tracker.configuration.SubjectConfiguration; +import com.snowplowanalytics.snowplow.tracker.configuration.TrackerConfiguration; +import com.snowplowanalytics.snowplow.tracker.emitter.BatchEmitter; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +public class Snowplow { + + private static final Map trackers = new HashMap<>(); + private static Tracker defaultTracker; + + /** + * @return the stored tracker namespaces + */ + public static Set getInstancedTrackerNamespaces() { + return trackers.keySet(); + } + + /** + * @return the default Tracker, or null if no default is set + */ + public static Tracker getDefaultTracker() { + return defaultTracker; + } + + /** + * Set a specific Tracker instance as the default tracker. The Tracker will be added to the Snowplow class if its + * namespace is not already stored. + * + * The first Tracker created using createTracker() or registered with registerTracker() + * will automatically be the default tracker; this method is intended for use + * with multiple trackers. + * + * @param tracker the tracker to use as default + */ + public static void setDefaultTracker(Tracker tracker) { + if (!trackers.containsKey(tracker.getNamespace())) { + Snowplow.registerTracker(tracker); + } + + defaultTracker = tracker; + } + + /** + * Set a registered Tracker as the default tracker, using its namespace. + * + * @param namespace the namespace of the tracker to set as default + * @return true if the tracker was found and set + */ + public static boolean setDefaultTracker(String namespace) { + if (trackers.containsKey(namespace)) { + defaultTracker = trackers.get(namespace); + return true; + } + + return false; + } + + /** + * Create a Snowplow tracker using Configuration objects. + * + * @param trackerConfig a TrackerConfiguration + * @param networkConfig a NetworkConfiguration (will be used to create an OkHttpClientAdapter) + * @param emitterConfig an EmitterConfiguration (will be used to create a BatchEmitter) + * @param subjectConfig a SubjectConfiguration + * @return the created Tracker + */ + public static Tracker createTracker(TrackerConfiguration trackerConfig, + NetworkConfiguration networkConfig, + EmitterConfiguration emitterConfig, + SubjectConfiguration subjectConfig) { + Subject subject = null; + if (subjectConfig != null) { + subject = new Subject(subjectConfig); + } + + BatchEmitter emitter = new BatchEmitter(networkConfig, emitterConfig); + Tracker tracker = new Tracker(trackerConfig, emitter, subject); + registerTracker(tracker); + return tracker; + } + + /** + * Create a Snowplow tracker with default configuration by providing three parameters. + * + * @param namespace unique identifier for the Tracker instance + * @param appId application ID + * @param collectorUrl collector endpoint + * @return the created Tracker + */ + public static Tracker createTracker(String namespace, String appId, String collectorUrl) { + TrackerConfiguration trackerConfig = new TrackerConfiguration(namespace, appId); + NetworkConfiguration networkConfig = new NetworkConfiguration(collectorUrl); + + return createTracker(trackerConfig, networkConfig, new EmitterConfiguration(), null); + } + + /** + * Create a Snowplow tracker using Configuration objects. + * + * @param trackerConfig a TrackerConfiguration + * @param networkConfig a NetworkConfiguration + * @param emitterConfig an EmitterConfiguration + * @return the created Tracker + */ + public static Tracker createTracker(TrackerConfiguration trackerConfig, + NetworkConfiguration networkConfig, + EmitterConfiguration emitterConfig) { + return createTracker(trackerConfig, networkConfig, emitterConfig, null); + } + + /** + * Create a Snowplow tracker using Configuration objects. + * + * @param trackerConfig a TrackerConfiguration + * @param networkConfig a NetworkConfiguration + * @return the created Tracker + */ + public static Tracker createTracker(TrackerConfiguration trackerConfig, + NetworkConfiguration networkConfig) { + return createTracker(trackerConfig, networkConfig, new EmitterConfiguration(), null); + } + + /** + * Create a Snowplow tracker using Configuration objects. + * + * @param trackerConfig a TrackerConfiguration + * @param networkConfig a NetworkConfiguration + * @param subjectConfig a SubjectConfiguration + * @return the created Tracker + */ + public static Tracker createTracker(TrackerConfiguration trackerConfig, + NetworkConfiguration networkConfig, + SubjectConfiguration subjectConfig) { + return createTracker(trackerConfig, networkConfig, new EmitterConfiguration(), subjectConfig); + } + + /** + * Register a Tracker instance that was created manually, not via the Snowplow.createTracker() method. + * + * @param tracker a Tracker instance + */ + public static void registerTracker(Tracker tracker) { + String namespace = tracker.getNamespace(); + if (trackers.containsKey(namespace)) { + throw new IllegalArgumentException("Tracker with this namespace already exists."); + } + + trackers.put(namespace, tracker); + + if (defaultTracker == null) { + defaultTracker = tracker; + } + } + + /** + * Get a Tracker by its namespace + * + * @param namespace the namespace of the tracker to retrieve + * @return the retrieved tracker + */ + public static Tracker getTracker(String namespace) { + return trackers.get(namespace); + } + + /** + * Unregister a Tracker, using its namespace. + * + * @param namespace the namespace of the tracker to remove + * @return true if the tracker was found and removed + */ + public static boolean removeTracker(String namespace) { + Tracker removedTracker = trackers.remove(namespace); + if ((defaultTracker != null) && defaultTracker.getNamespace().equals(namespace)) { + defaultTracker = null; + } + return removedTracker != null; + } + + /** + * Unregister a Tracker. + * + * @param tracker the tracker to remove + * @return true if the tracker was found and removed + */ + public static boolean removeTracker(Tracker tracker) { + return removeTracker(tracker.getNamespace()); + } + + /** + * Clear (unregister) all trackers. + */ + public static void reset() { + trackers.clear(); + defaultTracker = null; + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/Subject.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/Subject.java index d95fe1d0..bba82a15 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/Subject.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/Subject.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. * * This program is licensed to you under the Apache License Version 2.0, * and you may not use this file except in compliance with the Apache License Version 2.0. @@ -10,13 +10,318 @@ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ - package com.snowplowanalytics.snowplow.tracker; -public class Subject extends com.snowplowanalytics.snowplow.tracker.core.Subject { +// Java +import java.util.HashMap; +import java.util.Map; + +// This library +import com.snowplowanalytics.snowplow.tracker.configuration.SubjectConfiguration; +import com.snowplowanalytics.snowplow.tracker.constants.Parameter; + +/** + * An object for managing extra event decoration. + * All the properties are optional. However, the timezone is set by default, + * to that of the server. + */ +public class Subject { + + private HashMap standardPairs = new HashMap<>(); + + /** + * Creates a Subject instance from a SubjectConfiguration. + * + * @param subjectConfig a SubjectConfiguration + */ + public Subject(SubjectConfiguration subjectConfig) { + setUserId(subjectConfig.getUserId()); + setScreenResolution(subjectConfig.getScreenResWidth(), subjectConfig.getScreenResHeight()); + setViewPort(subjectConfig.getViewPortWidth(), subjectConfig.getViewPortHeight()); + setColorDepth(subjectConfig.getColorDepth()); + setTimezone(subjectConfig.getTimezone()); + setLanguage(subjectConfig.getLanguage()); + setIpAddress(subjectConfig.getIpAddress()); + setUseragent(subjectConfig.getUseragent()); + setNetworkUserId(subjectConfig.getNetworkUserId()); + setDomainUserId(subjectConfig.getDomainUserId()); + setDomainSessionId(subjectConfig.getDomainSessionId()); + } + /** + * Creates a Subject instance with default configuration (only the timezone is set). + */ public Subject() { - super(); + this(new SubjectConfiguration()); + } + + /** + * Creates a new {@link Subject} object based on the map of another {@link Subject} object. + * @param subject The subject from which the map is copied. + */ + public Subject(Subject subject){ + standardPairs.putAll(subject.getSubject()); + } + + /** + * Sets the User ID + * + * @param userId a user id string + */ + public void setUserId(String userId) { + if (userId != null) { + this.standardPairs.put(Parameter.UID, userId); + } + } + + /** + * Sets the User ID and returns itself + * + * @param userId a user id string + * @return itself + */ + public Subject userId(String userId) { + this.setUserId(userId); + return this; + } + + /** + * Sets the screen res parameter + * + * @param width a width integer + * @param height a height integer + */ + public void setScreenResolution(int width, int height) { + if (width > 0 && height > 0) { + String res = Integer.toString(width) + "x" + Integer.toString(height); + this.standardPairs.put(Parameter.RESOLUTION, res); + } + } + + /** + * Sets the screen res parameter and returns itself + * + * @param width a width integer + * @param height a height integer + * @return itself + */ + public Subject screenResolution(int width, int height) { + this.setScreenResolution(width, height); + return this; + } + + /** + * Sets the view port parameter + * + * @param width a width integer + * @param height a height integer + */ + public void setViewPort(int width, int height) { + if (width > 0 && height > 0) { + String res = Integer.toString(width) + "x" + Integer.toString(height); + this.standardPairs.put(Parameter.VIEWPORT, res); + } + } + + /** + * Sets the view port parameter and returns itself + * + * @param width a width integer + * @param height a height integer + * @return itself + */ + public Subject viewPort(int width, int height) { + this.setViewPort(width, height); + return this; + } + + /** + * Sets the color depth parameter + * + * @param depth a color depth integer + */ + public void setColorDepth(int depth) { + if (depth > 0) { + this.standardPairs.put(Parameter.COLOR_DEPTH, Integer.toString(depth)); + } + } + + /** + * Sets the color depth parameter and returns itself + * + * @param depth a color depth integer + * @return itself + */ + public Subject colorDepth(int depth) { + this.setColorDepth(depth); + return this; + } + + /** + * Sets the timezone parameter. Note that timezone is set by default to the server's timezone + * (`TimeZone tz = Calendar.getInstance().getTimeZone().getID()`); + * + * @param timezone a timezone string + */ + public void setTimezone(String timezone) { + if (timezone != null) { + this.standardPairs.put(Parameter.TIMEZONE, timezone); + } } + /** + * Sets the timezone parameter and returns itself. + * Note that timezone is set by default to the server's timezone + * (`TimeZone tz = Calendar.getInstance().getTimeZone().getID()`) + * + * @param timezone a timezone string + * @return itself + */ + public Subject timezone(String timezone) { + this.setTimezone(timezone); + return this; + } + + /** + * Sets the language parameter + * + * @param language a language string + */ + public void setLanguage(String language) { + if (language != null) { + this.standardPairs.put(Parameter.LANGUAGE, language); + } + } + + /** + * Sets the language parameter and returns itself + * + * @param language a language string + * @return itself + */ + public Subject language(String language) { + this.setLanguage(language); + return this; + } + + /** + * User inputted ip address for the subject. + * + * @param ipAddress an ip address + */ + public void setIpAddress(String ipAddress) { + if (ipAddress != null) { + this.standardPairs.put(Parameter.IP_ADDRESS, ipAddress); + } + } + + /** + * Sets the user inputted ip address for the subject and returns itself + * + * @param ipAddress a ipAddress string + * @return itself + */ + public Subject ipAddress(String ipAddress) { + this.setIpAddress(ipAddress); + return this; + } + + /** + * User inputted useragent for the subject. + * + * @param useragent a useragent + */ + public void setUseragent(String useragent) { + if (useragent != null) { + this.standardPairs.put(Parameter.USERAGENT, useragent); + } + } + + /** + * Sets the user inputted useragent for the subject and returns itself + * + * @param useragent a useragent string + * @return itself + */ + public Subject useragent(String useragent) { + this.setUseragent(useragent); + return this; + } + + /** + * User inputted Domain User Id for the subject. + * + * @param domainUserId a domain user id + */ + public void setDomainUserId(String domainUserId) { + if (domainUserId != null) { + this.standardPairs.put(Parameter.DOMAIN_UID, domainUserId); + } + } + + /** + * Sets the user inputted Domain User Id for the subject and returns itself + * + * @param domainUserId a domainUserId string + * @return itself + */ + public Subject domainUserId(String domainUserId) { + this.setDomainUserId(domainUserId); + return this; + } + + /** + * User inputted Domain Session ID for the subject. + * + * @param domainSessionId a domain session id + */ + public void setDomainSessionId(String domainSessionId) { + if (domainSessionId != null) { + this.standardPairs.put(Parameter.SESSION_UID, domainSessionId); + } + } + + /** + * Sets the user inputted Domain Session ID for the subject and returns itself + * + * @param domainSessionId a domainSessionId string + * @return itself + */ + public Subject domainSessionId(String domainSessionId) { + this.setDomainSessionId(domainSessionId); + return this; + } + + /** + * User inputted Network User ID for the subject. + * This overrides the network user ID set by the Collector in response Cookies. + * + * @param networkUserId a network user id + */ + public void setNetworkUserId(String networkUserId) { + if (networkUserId != null) { + this.standardPairs.put(Parameter.NETWORK_UID, networkUserId); + } + } + + /** + * Sets the user inputted Network User ID for the subject and returns itself. + * This overrides the network user ID set by the Collector in response Cookies. + * + * @param networkUserId a networkUserId string + * @return itself + */ + public Subject networkUserId(String networkUserId) { + this.setNetworkUserId(networkUserId); + return this; + } + + /** + * Gets the Subject pairs. + * + * @return the stored k-v pairs + */ + public Map getSubject() { + return this.standardPairs; + } } diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/Tracker.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/Tracker.java index aaca4361..8a75e367 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/Tracker.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/Tracker.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. * * This program is licensed to you under the Apache License Version 2.0, * and you may not use this file except in compliance with the Apache License Version 2.0. @@ -10,55 +10,279 @@ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ - package com.snowplowanalytics.snowplow.tracker; -import com.snowplowanalytics.snowplow.tracker.core.Subject; -import com.snowplowanalytics.snowplow.tracker.core.emitter.Emitter; +import com.snowplowanalytics.snowplow.tracker.configuration.TrackerConfiguration; +import com.snowplowanalytics.snowplow.tracker.constants.Constants; +import com.snowplowanalytics.snowplow.tracker.constants.Parameter; +import com.snowplowanalytics.snowplow.tracker.emitter.Emitter; +import com.snowplowanalytics.snowplow.tracker.events.*; +import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerParameters; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +import java.util.*; + +/** + * Allows tracking of Events. + */ +public class Tracker { + + private Emitter emitter; + private Subject subject; + private final TrackerParameters parameters; + + /** + * Creates a new Snowplow Tracker. + * + * @param trackerConfig a TrackerConfiguration object + * @param emitter an Emitter + * @param subject a Subject + * + */ + public Tracker(TrackerConfiguration trackerConfig, Emitter emitter, Subject subject) { + + // Precondition checks + Objects.requireNonNull(emitter); + Objects.requireNonNull(trackerConfig.getNamespace()); + Objects.requireNonNull(trackerConfig.getAppId()); + if (trackerConfig.getNamespace().isEmpty()) { + throw new IllegalArgumentException("namespace cannot be empty"); + } + if (trackerConfig.getAppId().isEmpty()) { + throw new IllegalArgumentException("appId cannot be empty"); + } + + this.parameters = new TrackerParameters(trackerConfig.getAppId(), trackerConfig.getPlatform(), trackerConfig.getNamespace(), Version.TRACKER, trackerConfig.isBase64Encoded()); + this.emitter = emitter; + this.subject = subject; -public class Tracker extends com.snowplowanalytics.snowplow.tracker.core.Tracker { + } + + /** + * Creates a new Snowplow Tracker. + * + * @param trackerConfig a TrackerConfiguration object + * @param emitter an Emitter + * + */ + public Tracker(TrackerConfiguration trackerConfig, Emitter emitter) { + this(trackerConfig, emitter, null); + } + + // --- Setters + + /** + * Change the Emitter used to send events. + * + * @param emitter a new emitter + */ + public void setEmitter(Emitter emitter) { + this.emitter = emitter; + } + + /** + * Sets a new Subject object which will get attached to + * each event payload. + * + * @param subject the new Subject + */ + public void setSubject(Subject subject) { + this.subject = subject; + } + + // --- Getters + + /** + * @return the emitter associated with the tracker + */ + public Emitter getEmitter() { + return this.emitter; + } + + /** + * @return the Tracker-associated Subject + */ + public Subject getSubject() { + return this.subject; + } + + /** + * The Java tracker release version, e.g. 0.12.0. + * + * @return the tracker version + */ + public String getTrackerVersion() { + return this.parameters.getTrackerVersion(); + } /** - * @param emitter Emitter to which events will be sent - * @param namespace Identifier for the Tracker instance - * @param appId Application ID + * @return the trackers namespace */ - public Tracker(com.snowplowanalytics.snowplow.tracker.core.emitter.Emitter emitter, String namespace, String appId) { - super(emitter, namespace, appId); - super.setTrackerVersion(Version.TRACKER); + public String getNamespace() { + return this.parameters.getNamespace(); } /** - * @param emitter Emitter to which events will be sent - * @param subject Subject to be tracked - * @param namespace Identifier for the Tracker instance - * @param appId Application ID + * @return the tracker Application ID */ - public Tracker(Emitter emitter, com.snowplowanalytics.snowplow.tracker.core.Subject subject, String namespace, String appId) { - super(emitter, subject, namespace, appId); - super.setTrackerVersion(Version.TRACKER); + public String getAppId() { + return this.parameters.getAppId(); } /** - * @param emitter Emitter to which events will be sent - * @param namespace Identifier for the Tracker instance - * @param appId Application ID - * @param base64Encoded Whether JSONs in the payload should be base-64 encoded + * @return the base64 setting of the tracker */ - public Tracker(Emitter emitter, String namespace, String appId, boolean base64Encoded) { - super(emitter, namespace, appId, base64Encoded); - super.setTrackerVersion(Version.TRACKER); + public boolean getBase64Encoded() { + return this.parameters.getBase64Encoded(); } /** - * @param emitter Emitter to which events will be sent - * @param subject Subject to be tracked - * @param namespace Identifier for the Tracker instance - * @param appId Application ID - * @param base64Encoded Whether JSONs in the payload should be base-64 encoded + * @return the Tracker platform, e.g. "srv" */ - public Tracker(Emitter emitter, Subject subject, String namespace, String appId, boolean base64Encoded) { - super(emitter, subject, namespace, appId, base64Encoded); - super.setTrackerVersion(Version.TRACKER); + public DevicePlatform getPlatform() { + return this.parameters.getPlatform(); } + + /** + * @return the wrapper containing the Tracker parameters + */ + public TrackerParameters getParameters() { + return parameters; + } + + // --- Event Tracking Functions + + /** + * Handles tracking the different types of events. + * + * A TrackerPayload object - or more than one, in the case of eCommerceTransaction events - + * will be created from the Event. This is passed to the configured Emitter. + * If the event was successfully added to the Emitter buffer for sending, + * a list containing the payload's eventId string (a UUID) is returned. + * EcommerceTransactions will return all the relevant eventIds in the list. + * If the Emitter event buffer is full, the payload will be lost. In this case, this method + * returns a list containing null. + *

+ * Implementation note: As a side effect of adding a payload to the Emitter, + * it triggers an Emitter thread to emit a batch of events. + * + * @param event the event to track + * @return a list of eventIDs (UUIDs) + */ + public List track(Event event) { + List results = new ArrayList<>(); + // a list because Ecommerce events become multiple Payloads + List processedEvents = eventTypeSpecificPreProcessing(event); + for (Event processedEvent : processedEvents) { + // Event ID (eid) and device_created_timestamp (dtm) are generated now when + // the TrackerPayload is created + TrackerPayload payload = (TrackerPayload) processedEvent.getPayload(); + + addTrackerParameters(payload); + addContext(processedEvent, payload); + addSubject(processedEvent, payload); + + boolean addedToBuffer = emitter.add(payload); + if (addedToBuffer) { + results.add(payload.getEventId()); + } else { + results.add(null); + } + } + return results; + } + + private List eventTypeSpecificPreProcessing(Event event) { + // Different event types must be processed in slightly different ways. + // EcommerceTransaction events are an outlier, as they are processed into + // multiple payloads (a "tr" event plus one "ti" event per item). + // Because of this, this method returns a list of Events. + List eventList = new ArrayList<>(); + final Class eventClass = event.getClass(); + + if (eventClass.equals(SelfDescribing.class)) { + // Need to set the Base64 rule for SelfDescribing events + final SelfDescribing selfDescribing = (SelfDescribing) event; + selfDescribing.setBase64Encode(parameters.getBase64Encoded()); + eventList.add(selfDescribing); + + } else if (eventClass.equals(EcommerceTransaction.class)) { + final EcommerceTransaction ecommerceTransaction = (EcommerceTransaction) event; + eventList.add(ecommerceTransaction); + + // Track each item individually + eventList.addAll(ecommerceTransaction.getItems()); + + } else if (eventClass.equals(Timing.class) || eventClass.equals(ScreenView.class)) { + // Timing and ScreenView events are wrapper classes for SelfDescribing events + // Need to create SelfDescribing events from them to send. + final SelfDescribing selfDescribing = SelfDescribing.builder() + .eventData((SelfDescribingJson) event.getPayload()) + .customContext(event.getContext()) + .trueTimestamp(event.getTrueTimestamp()) + .subject(event.getSubject()) + .build(); + + selfDescribing.setBase64Encode(parameters.getBase64Encoded()); + eventList.add(selfDescribing); + + } else { + eventList.add(event); + } + return eventList; + } + + private void addTrackerParameters(TrackerPayload payload) { + payload.add(Parameter.PLATFORM, parameters.getPlatform().toString()); + payload.add(Parameter.APP_ID, parameters.getAppId()); + payload.add(Parameter.NAMESPACE, parameters.getNamespace()); + payload.add(Parameter.TRACKER_VERSION, parameters.getTrackerVersion()); + } + + private void addContext(Event event, TrackerPayload payload) { + List entities = event.getContext(); + + // Build the final context and add it to the payload + if (entities != null && entities.size() > 0) { + SelfDescribingJson envelope = getFinalContext(entities); + payload.addMap(envelope.getMap(), parameters.getBase64Encoded(), Parameter.CONTEXT_ENCODED, Parameter.CONTEXT); + } + } + + /** + * Builds the final event context. + * + * @param entities the base event context + * @return the final event context json with many entities inside + */ + private SelfDescribingJson getFinalContext(List entities) { + List> entityMaps = new LinkedList<>(); + for (SelfDescribingJson selfDescribingJson : entities) { + entityMaps.add(selfDescribingJson.getMap()); + } + return new SelfDescribingJson(Constants.SCHEMA_CONTEXTS, entityMaps); + } + + private void addSubject(Event event, TrackerPayload payload) { + Subject eventSubject = event.getSubject(); + + // Add subject if available + if (eventSubject != null) { + payload.addMap(new HashMap<>(eventSubject.getSubject())); + } else if (subject != null) { + payload.addMap(new HashMap<>(subject.getSubject())); + } + } + + /** + * Attempts to send all remaining events, then shuts down the Emitter so that no more events can be sent. + * This method calls the Emitter.close() method. For the default BatchEmitter, + * this stops the ScheduledExecutorService and the thread pool (non-daemon threads). + * There is no way to restart the Emitter after calling close(). + */ + public void close() { + emitter.close(); + } + } diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/TransactionItem.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/TransactionItem.java deleted file mode 100644 index 0b6da54d..00000000 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/TransactionItem.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ - -package com.snowplowanalytics.snowplow.tracker; - -import com.snowplowanalytics.snowplow.tracker.core.payload.SchemaPayload; - -import java.util.List; - -public class TransactionItem extends com.snowplowanalytics.snowplow.tracker.core.TransactionItem { - - public TransactionItem(String order_id, String sku, double price, int quantity, String name, - String category, String currency) { - super(order_id, sku, price, quantity, name, category, currency); - } - - public TransactionItem(String order_id, String sku, double price, int quantity, String name, - String category, String currency, List context) { - super(order_id, sku, price, quantity, name, category, currency, context); - } -} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/Utils.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/Utils.java new file mode 100644 index 00000000..af7e94a3 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/Utils.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker; + +import java.nio.charset.Charset; +import java.util.*; +import java.net.URL; +import java.net.URLEncoder; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Provides basic Utilities for the Snowplow Tracker. + */ +public class Utils { + + private static final Logger LOGGER = LoggerFactory.getLogger(Utils.class); + private static final ObjectMapper objectMapper + = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + + // Tracker Utils + + /** + * Returns the current System time + * as a String. + * + * @return the system time as a string + */ + public static String getTimestamp() { + return Long.toString(System.currentTimeMillis()); + } + + /** + * Generates a random UUID for + * each event. + * + * @return a UUID string + */ + public static String getEventId() { + return UUID.randomUUID().toString(); + } + + /** + * Returns a Transaction ID integer. + * + * @return a new random Transaction ID + */ + public static int getTransactionId() { + Random r = new Random(); + return r.nextInt(999999 - 100000 + 1) + 100000; + } + + // Emitter Utils + + /** + * Validates a uri and checks that it is valid + * before being used by the emitter. + * + * @param url the uri to validate + * @return whether the uri is valid or not + */ + public static boolean isValidUrl(String url) { + try { + new URL(url).toURI(); + return true; + } catch (Exception e) { + LOGGER.error("Invalid URI"); + LOGGER.debug("URI {} is not valid: {}", url, e.getMessage()); + return false; + } + } + + // Subject Utils + + /** + * Gets the default timezone of the server running + * the library. + * + * @return the timezone id string + */ + public static String getTimezone() { + TimeZone tz = Calendar.getInstance().getTimeZone(); + return tz.getID(); + } + + // Payload Utils + + /** + * Encodes a string into Base64. + * + * @param string the string too encode + * @param charset the charset used when base64 encoding string + * @return a Base64 encoded string + */ + public static String base64Encode(String string, Charset charset) { + return Base64.getEncoder().encodeToString(string.getBytes(charset)); + } + + /** + * Processes a Map into a JSON String or returns an empty + * String if it fails + * + * @param map the map to process into a JSON String + * @return the final JSON String + */ + public static String mapToJSONString(Map map) { + String jString = ""; + try { + jString = objectMapper.writeValueAsString(map); + } catch (JsonProcessingException e) { + LOGGER.error("Could not process Map into JSON String"); + LOGGER.debug("Could not process Map {} into JSON String: {}", map, e.getMessage()); + } + return jString; + } + + /** + * Builds a QueryString from a Map of Name-Value pairs. + * + * @param map The map to convert + * @return the QueryString ready for sending + */ + public static String mapToQueryString(Map map) { + StringBuilder sb = new StringBuilder(); + for (String key : map.keySet()) { + if (sb.length() > 0) { + sb.append("&"); + } + + String encodedKey = urlEncodeUTF8(key); + String encodedVal = urlEncodeUTF8(map.get(key)); + + // Do not add empty Keys + if (!encodedKey.isEmpty()) { + sb.append(String.format("%s=%s", encodedKey, encodedVal)); + } + } + return sb.toString(); + } + + /** + * Encodes an Object in UTF-8. + * Will attempt to cast the object to a String and then encode. + * + * @param o The object to encode + * @return either the encoded String or an empty + * String if it fails. + */ + public static String urlEncodeUTF8(Object o) { + try { + String s = (String) o; + String encoded = URLEncoder.encode(s, "UTF-8"); + return encoded.replaceAll("\\+", "%20"); + } catch (Exception e) { + LOGGER.error("Object could not be encoded"); + LOGGER.debug("Object {} could not be encoded: {}", o, e.getMessage()); + return ""; + } + } + + /** + * Count the number of bytes a string will occupy when UTF-8 encoded + * + * @param s the String to process + * @return number Length of s in bytes when UTF-8 encoded + */ + public static long getUTF8Length(String s) { + long len = 0; + for (int i = 0; i < s.length(); i++) { + char code = s.charAt(i); + if (code <= 0x7f) { + len += 1; + } else if (code <= 0x7ff) { + len += 2; + } else if (code >= 0xd800 && code <= 0xdfff) { + len += 4; i++; + } else if (code < 0xffff) { + len += 3; + } else { + len += 4; + } + } + return len; + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/Version.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/Version.java deleted file mode 100644 index 2d7c8e54..00000000 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/Version.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ - -package com.snowplowanalytics.snowplow.tracker; - -public class Version { - static final String TRACKER = "java-0.5.2"; - static final String VERSION = "0.5.2"; -} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/EmitterConfiguration.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/EmitterConfiguration.java new file mode 100644 index 00000000..e663db54 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/EmitterConfiguration.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.configuration; + +import com.snowplowanalytics.snowplow.tracker.emitter.EmitterCallback; +import com.snowplowanalytics.snowplow.tracker.emitter.EventStore; + +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; + +public class EmitterConfiguration { + + private int batchSize; // Optional + private int bufferCapacity; // Optional + private EventStore eventStore; // Optional + private Map customRetryForStatusCodes; // Optional + private int threadCount; // Optional + private ScheduledExecutorService requestExecutorService; // Optional + private EmitterCallback callback; // Optional + + // Getters and Setters + + /** + * Returns the number of events to send per request (batched). + * @return the batch size + */ + public int getBatchSize() { + return batchSize; + } + + /** + * Returns the maximum number of events to buffer in memory. + * @return maximum buffer capacity + */ + public int getBufferCapacity() { + return bufferCapacity; + } + + /** + * Returns the EventStore used to buffer events. + * @return EventStore instance + */ + public EventStore getEventStore() { + return eventStore; + } + + /** + * Returns the custom configuration for HTTP status codes. "True" means the + * @return map of integers (status codes) to booleans (true for retry and false for not retry) + */ + public Map getCustomRetryForStatusCodes() { + return customRetryForStatusCodes; + } + + /** + * Returns the number of threads used for event sending using the ScheduledExecutorService. + * @return thread count + */ + public int getThreadCount() { + return threadCount; + } + + /** + * Returns the ScheduledExecutorService used for sending events. + * @return ScheduledExecutorService object + */ + public ScheduledExecutorService getRequestExecutorService() { + return requestExecutorService; + } + + /** + * Returns the custom callback which is called when events are successfully sent to the collector, + * or after certain failure conditions. + * + * @return EmitterCallback object + */ + public EmitterCallback getCallback() { + return callback; + } + + // Constructor + + /** + * Create an EmitterConfiguration instance. The default configuration is: + * 50 batched events per request; + * maximum 10 000 events buffered in memory; + * 50 threads; + * no retry for request status codes 400, 401, 403, 410 or 422; + * and OkHttp (OkHttpClientAdapter) used for HTTP requests. + */ + public EmitterConfiguration() { + batchSize = 50; + bufferCapacity = 10000; + eventStore = null; + customRetryForStatusCodes = null; + threadCount = 50; + requestExecutorService = null; + callback = null; + } + + // Builder methods + + /** + * The default batch size is 50. + * + * @param batchSize The count of events to send in one HTTP request + * @return itself + */ + public EmitterConfiguration batchSize(int batchSize) { + this.batchSize = batchSize; + return this; + } + + /** + * The default buffer capacity is 10 000 events. + * When the buffer is full (due to network outage), new events are lost. + * + * @param bufferCapacity The maximum capacity of the default InMemoryEventStore event buffer + * @return itself + */ + public EmitterConfiguration bufferCapacity(int bufferCapacity) { + this.bufferCapacity = bufferCapacity; + return this; + } + + /** + * The default EventStore is InMemoryEventStore. + * + * @param eventStore The EventStore to use + * @return itself + */ + public EmitterConfiguration eventStore(EventStore eventStore) { + this.eventStore = eventStore; + return this; + } + + /** + * Set custom retry rules for HTTP status codes received in emit responses from the Collector. + * By default, retry will not occur for status codes 400, 401, 403, 410 or 422. This can be overridden here. + * Note that 2xx codes will never retry as they are considered successful. + * @param customRetryForStatusCodes Mapping of integers (status codes) to booleans (true for retry and false for not retry) + * @return itself + */ + public EmitterConfiguration customRetryForStatusCodes(Map customRetryForStatusCodes) { + this.customRetryForStatusCodes = customRetryForStatusCodes; + return this; + } + + /** + * Sets the Thread Count for the ScheduledExecutorService (default is 50). + * + * @param threadCount the size of the thread pool + * @return itself + */ + public EmitterConfiguration threadCount(int threadCount) { + this.threadCount = threadCount; + return this; + } + + /** + * Set a custom ScheduledExecutorService to send http requests (default is ScheduledThreadPoolExecutor). + *

+ * Implementation note: Be aware that calling `close()` on a BatchEmitter instance + * has a side-effect and will shutdown that ExecutorService. + * + * @param requestExecutorService the ScheduledExecutorService to use + * @return itself + */ + public EmitterConfiguration requestExecutorService(ScheduledExecutorService requestExecutorService) { + this.requestExecutorService = requestExecutorService; + return this; + } + + /** + * Provide a custom EmitterCallback to access successfully sent or failed event payloads. + * + * @param callback an EmitterCallback + * @return itself + */ + public EmitterConfiguration callback(EmitterCallback callback) { + this.callback = callback; + return this; + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/NetworkConfiguration.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/NetworkConfiguration.java new file mode 100644 index 00000000..7c33d101 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/NetworkConfiguration.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.configuration; + +import com.snowplowanalytics.snowplow.tracker.http.HttpClientAdapter; + + +public class NetworkConfiguration { + + private HttpClientAdapter httpClientAdapter = null; // Optional + private String collectorUrl = null; // Required if not specifying a httpClientAdapter + + // Getters and Setters + + /** + * Returns the HttpClientAdapter used. + * @return HttpClientAdapter object + */ + public HttpClientAdapter getHttpClientAdapter() { + return httpClientAdapter; + } + + /** + * Returns the event collector URL endpoint. + * @return collector URL + */ + public String getCollectorUrl() { + return collectorUrl; + } + + // Constructors + + /** + * Create a NetworkConfiguration instance and specify a custom HttpClientAdapter to use + * (the default is OkHttpClientAdapter). + * + * @param httpClientAdapter the adapter to use + */ + public NetworkConfiguration(HttpClientAdapter httpClientAdapter) { + this.httpClientAdapter = httpClientAdapter; + } + + /** + * Create a NetworkConfiguration instance with a collector endpoint URL. The URL will be used + * to create the default OkHttpClientAdapter. + * + * @param collectorUrl the url for the default httpClientAdapter + */ + public NetworkConfiguration(String collectorUrl) { + this.collectorUrl = collectorUrl; + } + + // Builder methods + + /** + * Sets a custom HttpClientAdapter (default is OkHttpClientAdapter). + * + * @param httpClientAdapter the adapter to use + * @return itself + */ + public NetworkConfiguration httpClientAdapter(HttpClientAdapter httpClientAdapter) { + this.httpClientAdapter = httpClientAdapter; + return this; + } + + /** + * Sets the endpoint url for when a httpClientAdapter is not specified. + * It will be used to create the default OkHttpClientAdapter. + * + * @param collectorUrl the url for the default httpClientAdapter + * @return itself + */ + public NetworkConfiguration collectorUrl(String collectorUrl) { + this.collectorUrl = collectorUrl; + return this; + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/SubjectConfiguration.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/SubjectConfiguration.java new file mode 100644 index 00000000..2b4eebce --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/SubjectConfiguration.java @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.configuration; + +import com.snowplowanalytics.snowplow.tracker.Utils; + +public class SubjectConfiguration { + + private String userId; // Optional + private int screenResWidth; // Optional + private int screenResHeight; // Optional + private int viewPortWidth; // Optional + private int viewPortHeight; // Optional + private int colorDepth; // Optional + private String timezone; // Optional + private String language; // Optional + private String ipAddress; // Optional + private String useragent; // Optional + private String networkUserId; // Optional + private String domainUserId; // Optional + private String domainSessionId; // Optional + + // Getters and Setters + + /** + * Returns the user ID. + * @return user ID + */ + public String getUserId() { + return userId; + } + + /** + * Returns the screen resolution width, in pixels. + * @return screen width + */ + public int getScreenResWidth() { + return screenResWidth; + } + + /** + * Returns the screen resolution height, in pixels. + * @return screen height + */ + public int getScreenResHeight() { + return screenResHeight; + } + + /** + * Returns the viewport width, in pixels. + * @return viewport width + */ + public int getViewPortWidth() { + return viewPortWidth; + } + + /** + * Returns the viewport height, in pixels. + * @return viewport height + */ + public int getViewPortHeight() { + return viewPortHeight; + } + + /** + * Returns the color depth. + * @return color depth + */ + public int getColorDepth() { + return colorDepth; + } + + /** + * Returns the timezone. Automatically set by default to that of the server. + * @return timezone + */ + public String getTimezone() { + return timezone; + } + + /** + * Returns the device language. + * @return language + */ + public String getLanguage() { + return language; + } + + /** + * Returns the IP address. + * @return IP address + */ + public String getIpAddress() { + return ipAddress; + } + + /** + * Returns the useragent. + * @return useragent + */ + public String getUseragent() { + return useragent; + } + + /** + * Returns the network user ID (UUID string). + * @return network user ID + */ + public String getNetworkUserId() { + return networkUserId; + } + + /** + * Returns the domain user ID (UUID string). + * @return domain user ID + */ + public String getDomainUserId() { + return domainUserId; + } + + /** + * Returns the domain session ID (UUID string). + * @return domain session ID + */ + public String getDomainSessionId() { + return domainSessionId; + } + + // Constructor + + /** + * Create a Subject instance. By default, timezone is set to the server's timezone. + */ + public SubjectConfiguration() { + userId = null; // Optional + screenResWidth = 0; // Optional + screenResHeight = 0; // Optional + viewPortWidth = 0; // Optional + viewPortHeight = 0; // Optional + colorDepth = 0; // Optional + timezone = Utils.getTimezone(); // Optional + language = null; // Optional + ipAddress = null; // Optional + useragent = null; // Optional + networkUserId = null; // Optional + domainUserId = null; // Optional + domainSessionId = null; // Optional + } + + // Builder methods + + /** + * Set a unique user ID. + * @param userId a user ID + * @return itself + */ + public SubjectConfiguration userId(String userId) { + this.userId = userId; + return this; + } + + /** + * Set the screen resolution. + * @param width width in pixels + * @param height height in pixels + * @return itself + */ + public SubjectConfiguration screenResolution(int width, int height) { + screenResWidth = width; + screenResHeight = height; + return this; + } + + /** + * Set the viewport size. + * @param width width in pixels + * @param height height in pixels + * @return itself + */ + public SubjectConfiguration viewPort(int width, int height) { + viewPortWidth = width; + viewPortHeight = height; + return this; + } + + /** + * @param depth a color depth integer + * @return itself + */ + public SubjectConfiguration colorDepth(int depth) { + colorDepth = depth; + return this; + } + + /** + * Note that timezone is set by default to the server's timezone + * (`TimeZone tz = Calendar.getInstance().getTimeZone().getID()`) + * @param timezone a timezone string + * @return itself + */ + public SubjectConfiguration timezone(String timezone) { + this.timezone = timezone; + return this; + } + + /** + * @param language a language string + * @return itself + */ + public SubjectConfiguration language(String language) { + this.language = language; + return this; + } + + /** + * @param ipAddress a ipAddress string + * @return itself + */ + public SubjectConfiguration ipAddress(String ipAddress) { + this.ipAddress = ipAddress; + return this; + } + + /** + * @param useragent a useragent string + * @return itself + */ + public SubjectConfiguration useragent(String useragent) { + this.useragent = useragent; + return this; + } + + /** + * This overrides the network user ID set by the Collector in response Cookies. + * @param networkUserId a networkUserId string + * @return itself + */ + public SubjectConfiguration networkUserId(String networkUserId) { + this.networkUserId = networkUserId; + return this; + } + + /** + * @param domainUserId a domainUserId string + * @return itself + */ + public SubjectConfiguration domainUserId(String domainUserId) { + this.domainUserId = domainUserId; + return this; + } + + /** + * @param domainSessionId a domainSessionId string + * @return itself + */ + public SubjectConfiguration domainSessionId(String domainSessionId) { + this.domainSessionId = domainSessionId; + return this; + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/TrackerConfiguration.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/TrackerConfiguration.java new file mode 100644 index 00000000..8774e259 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/TrackerConfiguration.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.configuration; + +import com.snowplowanalytics.snowplow.tracker.DevicePlatform; + + +public class TrackerConfiguration { + private final String namespace; // Required + private final String appId; // Required + private DevicePlatform platform; // Optional + private boolean base64Encoded; // Optional + + // Getters and Setters + + /** + * Returns the unique tracker namespace. + * @return tracker namespace + */ + public String getNamespace() { + return namespace; + } + + /** + * Returns the application ID. + * @return application ID + */ + public String getAppId() { + return appId; + } + + /** + * Returns the DevicePlatform for the tracker. + * @return what platform the app is running on + */ + public DevicePlatform getPlatform() { + return platform; + } + + /** + * Returns whether JSONs in the payload are base-64 encoded. + * @return true if encoded + */ + public boolean isBase64Encoded() { + return base64Encoded; + } + + // Constructor + + /** + * Create a TrackerConfiguration instance. The namespace is the unique identifier for the instance. + * By default, the platform is ServerSideApp, and JSONs will be base64 encoded. + * + * @param namespace identifier for the Tracker instance + * @param appId application ID + */ + public TrackerConfiguration(String namespace, String appId) { + this.namespace = namespace; + this.appId = appId; + this.platform = DevicePlatform.ServerSideApp; + this.base64Encoded = true; + } + + // Builder methods + + /** + * The {@link DevicePlatform} the tracker is running on (default is "srv", ServerSideApp). + * + * @param platform The device platform the tracker is running on + * @return itself + */ + public TrackerConfiguration platform(DevicePlatform platform) { + this.platform = platform; + return this; + } + + /** + * Whether JSONs in the payload should be base-64 encoded (default is true) + * + * @param base64Encoded JSONs should be encoded or not + * @return itself + */ + public TrackerConfiguration base64Encoded(boolean base64Encoded) { + this.base64Encoded = base64Encoded; + return this; + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/constants/Constants.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/constants/Constants.java new file mode 100644 index 00000000..c2d573bd --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/constants/Constants.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.constants; + +/** + * Constants that apply to schemas, event types + * and sending protocols. + */ +public class Constants { + public static final String PROTOCOL_VENDOR = "com.snowplowanalytics.snowplow"; + public static final String PROTOCOL_VERSION = "tp2"; + + public static final String SCHEMA_PAYLOAD_DATA = "iglu:com.snowplowanalytics.snowplow/payload_data/jsonschema/1-0-4"; + public static final String SCHEMA_CONTEXTS = "iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-1"; + public static final String SCHEMA_SELF_DESCRIBING_EVENT = "iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0"; + public static final String SCHEMA_SCREEN_VIEW = "iglu:com.snowplowanalytics.snowplow/screen_view/jsonschema/1-0-0"; + public static final String SCHEMA_USER_TIMINGS = "iglu:com.snowplowanalytics.snowplow/timing/jsonschema/1-0-0"; + + public static final String POST_CONTENT_TYPE = "application/json; charset=utf-8"; + + public static final String EVENT_PAGE_VIEW = "pv"; + public static final String EVENT_STRUCTURED = "se"; + public static final String EVENT_SELF_DESCRIBING = "ue"; + public static final String EVENT_ECOMM = "tr"; + public static final String EVENT_ECOMM_ITEM = "ti"; +} diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Parameter.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/constants/Parameter.java similarity index 72% rename from snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Parameter.java rename to src/main/java/com/snowplowanalytics/snowplow/tracker/constants/Parameter.java index 2fd31e92..19ec1863 100644 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/Parameter.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/constants/Parameter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. * * This program is licensed to you under the Apache License Version 2.0, * and you may not use this file except in compliance with the Apache License Version 2.0. @@ -11,24 +11,33 @@ * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ -package com.snowplowanalytics.snowplow.tracker.core; +package com.snowplowanalytics.snowplow.tracker.constants; +/** + * More constants that define the event properties, which apply to schemas, event types + * and sending protocols. + */ public class Parameter { // General public static final String SCHEMA = "schema"; public static final String DATA = "data"; public static final String EVENT = "e"; public static final String EID = "eid"; - public static final String TIMESTAMP = "dtm"; + + public static final String TRUE_TIMESTAMP = "ttm"; + + public static final String DEVICE_CREATED_TIMESTAMP = "dtm"; + public static final String DEVICE_SENT_TIMESTAMP = "stm"; + public static final String TRACKER_VERSION = "tv"; - public static final String APPID = "aid"; + public static final String APP_ID = "aid"; public static final String NAMESPACE = "tna"; public static final String UID = "uid"; public static final String CONTEXT = "co"; public static final String CONTEXT_ENCODED = "cx"; - public static final String UNSTRUCTURED = "ue_pr"; - public static final String UNSTRUCTURED_ENCODED = "ue_px"; + public static final String SELF_DESCRIBING = "ue_pr"; + public static final String SELF_DESCRIBING_ENCODED = "ue_px"; // Subject class public static final String PLATFORM = "p"; @@ -37,6 +46,11 @@ public class Parameter { public static final String COLOR_DEPTH = "cd"; public static final String TIMEZONE = "tz"; public static final String LANGUAGE = "lang"; + public static final String IP_ADDRESS = "ip"; + public static final String USERAGENT = "ua"; + public static final String DOMAIN_UID = "duid"; + public static final String NETWORK_UID = "tnuid"; + public static final String SESSION_UID = "sid"; // Page View public static final String PAGE_URL = "url"; @@ -73,4 +87,10 @@ public class Parameter { // Screen View public static final String SV_ID = "id"; public static final String SV_NAME = "name"; + + // User Timing + public static final String UT_CATEGORY = "category"; + public static final String UT_VARIABLE = "variable"; + public static final String UT_TIMING = "timing"; + public static final String UT_LABEL = "label"; } diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchEmitter.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchEmitter.java new file mode 100644 index 00000000..9c2e7a13 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchEmitter.java @@ -0,0 +1,395 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.emitter; + +import java.io.Closeable; +import java.util.*; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import com.snowplowanalytics.snowplow.tracker.configuration.EmitterConfiguration; +import com.snowplowanalytics.snowplow.tracker.configuration.NetworkConfiguration; +import com.snowplowanalytics.snowplow.tracker.constants.Constants; +import com.snowplowanalytics.snowplow.tracker.constants.Parameter; +import com.snowplowanalytics.snowplow.tracker.http.HttpClientAdapter; +import com.snowplowanalytics.snowplow.tracker.http.OkHttpClientAdapter; +import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An emitter that emits a batch of events in a single HTTP request. + * It uses the POST method of the underlying HTTP adapter. + * + * When a new event (TrackerPayload) is received and added to the buffer, the BatchEmitter checks the + * number of buffered events. If it is equal to or greater than the `batchSize`, an attempt is made to send + * a batch of events as one request. Events are sent asynchronously. + * + * If the request is unsuccessful, the events are returned to the buffer. A delay is introduced for all + * event sending attempts. This increases exponentially until a request succeeds, when it is reset to 0. + * Retry will continue indefinitely. + * + * If the buffer becomes full due to network problems, newer events will be lost. + */ +public class BatchEmitter implements Emitter, Closeable { + + private static final Logger LOGGER = LoggerFactory.getLogger(BatchEmitter.class); + private boolean isClosing = false; + private final AtomicInteger retryDelay; + private final int maximumRetryDelay = 600000; // ms (10 min) + private int batchSize; + + private final HttpClientAdapter httpClientAdapter; + private final ScheduledExecutorService executor; + private final EventStore eventStore; + private final Map customRetryForStatusCodes; + private final EmitterCallback callback; + + /** + * Creates a BatchEmitter object from configuration objects. + * + * @param networkConfig a NetworkConfiguration object + * @param emitterConfig an EmitterConfiguration object + */ + public BatchEmitter(NetworkConfiguration networkConfig, EmitterConfiguration emitterConfig) { + // Precondition checks + if (emitterConfig.getThreadCount() <= 0) { + throw new IllegalArgumentException("threadCount must be greater than 0"); + } + if (emitterConfig.getBatchSize() <= 0) { + throw new IllegalArgumentException("batchSize must be greater than 0"); + } + if (emitterConfig.getBufferCapacity() <= 0) { + throw new IllegalArgumentException("bufferCapacity must be greater than 0"); + } + + if (networkConfig.getHttpClientAdapter() != null) { + httpClientAdapter = networkConfig.getHttpClientAdapter(); + } else { + Objects.requireNonNull(networkConfig.getCollectorUrl(), "Collector url must be specified if not using a httpClientAdapter"); + + httpClientAdapter = new OkHttpClientAdapter(networkConfig.getCollectorUrl()); + } + + retryDelay = new AtomicInteger(0); + batchSize = emitterConfig.getBatchSize(); + + if (emitterConfig.getCallback() != null) { + callback = emitterConfig.getCallback(); + } else { + callback = new EmitterCallback() { + @Override + public void onSuccess(List payloads) {} + @Override + public void onFailure(FailureType failureType, boolean willRetry, List payloads) {} + }; + } + + if (emitterConfig.getEventStore() != null) { + eventStore = emitterConfig.getEventStore(); + } else { + eventStore = new InMemoryEventStore(emitterConfig.getBufferCapacity()); + } + + if (emitterConfig.getCustomRetryForStatusCodes() != null) { + customRetryForStatusCodes = emitterConfig.getCustomRetryForStatusCodes(); + } else { + customRetryForStatusCodes = new HashMap<>(); + } + + if (emitterConfig.getRequestExecutorService() != null) { + executor = emitterConfig.getRequestExecutorService(); + } else { + executor = Executors.newScheduledThreadPool(emitterConfig.getThreadCount(), new EmitterThreadFactory()); + } + } + + /** + * Creates a BatchEmitter instance using a NetworkConfiguration. + * + * @param networkConfig a NetworkConfiguration object + */ + public BatchEmitter(NetworkConfiguration networkConfig) { + this(networkConfig, new EmitterConfiguration()); + } + + /** + * Adds a TrackerPayload to the EventStore buffer. + * If the buffer is full, the payload will be lost. + * + *

+ * Implementation note: As a side effect it triggers an Emitter thread to emit a batch of events. + * + * @param payload a TrackerPayload + * @return whether the payload has been successfully added to the buffer. + */ + @Override + public boolean add(final TrackerPayload payload) { + boolean result = eventStore.addEvent(payload); + + if (!isClosing) { + if (eventStore.size() >= batchSize) { + executor.schedule(getPostRequestRunnable(batchSize), retryDelay.get(), TimeUnit.MILLISECONDS); + } + } + + if (!result) { + LOGGER.error("Unable to add payload to emitter, emitter buffer is full"); + callback.onFailure(FailureType.TRACKER_STORAGE_FULL, false, Collections.singletonList(payload)); + } + + return result; + } + + /** + * Forces all the payloads currently in the buffer to be sent immediately, as a single request. + */ + @Override + public void flushBuffer() { + executor.schedule(getPostRequestRunnable(eventStore.size()), 0, TimeUnit.MILLISECONDS); + } + + /** + * Returns a List of Payloads that are in the buffer. + * + * @return the buffered events + */ + @Override + public List getBuffer() { + return eventStore.getAllEvents(); + } + + /** + * Customize the emitter batch size to any valid integer greater than zero. + * + * @param batchSize number of events to send in one request + */ + @Override + public void setBatchSize(final int batchSize) { + if (batchSize <= 0) { + throw new IllegalArgumentException("batchSize must be greater than 0"); + } + this.batchSize = batchSize; + } + + /** + * Gets the Emitter batchSize + * + * @return the batch size + */ + @Override + public int getBatchSize() { + return batchSize; + } + + int getRetryDelay() { + return retryDelay.get(); + } + + /** + * Checks whether the response code was a success or not. + * + * @param code the response code + * @return whether it is in the success range + */ + protected boolean isSuccessfulSend(final int code) { + return code >= 200 && code < 300; + } + + protected boolean shouldRetry(int code) { + // don't retry if successful + if (isSuccessfulSend(code)) { + return false; + } + + // status code has a custom retry rule + if (customRetryForStatusCodes.containsKey(code)) { + return Objects.requireNonNull(customRetryForStatusCodes.get(code)); + } + + // retry if status code is not in the list of no-retry status codes + Set dontRetryStatusCodes = new HashSet<>(Arrays.asList(400, 401, 403, 410, 422)); + return !dontRetryStatusCodes.contains(code); + } + + /** + * Returns a Runnable POST Request operation + * + * @param numberOfEvents the number of events to be sent in the request + * @return the new Runnable object + */ + private Runnable getPostRequestRunnable(int numberOfEvents) { + return () -> { + BatchPayload batchedEvents = null; + + // If the InMemoryEventStore queue is full when events are returned for retry, + // newer events are removed to make space + List eventsDeletedFromStorage = new ArrayList<>(); + + try { + batchedEvents = eventStore.getEventsBatch(numberOfEvents); + + if (batchedEvents == null || batchedEvents.size() == 0) { + return; + } + + List eventsInRequest = new ArrayList<>(batchedEvents.getPayloads()); + final SelfDescribingJson post = getFinalPost(eventsInRequest); + final int code = httpClientAdapter.post(post); + + // Process results + if (isSuccessfulSend(code)) { + LOGGER.debug("BatchEmitter successfully sent {} events: code: {}", eventsInRequest.size(), code); + retryDelay.set(0); + eventStore.cleanupAfterSendingAttempt(false, batchedEvents.getBatchId()); + callback.onSuccess(eventsInRequest); + + } else if (!shouldRetry(code)) { + LOGGER.debug("BatchEmitter failed to send {} events. No retry for code {}: events dropped", eventsInRequest.size(), code); + eventStore.cleanupAfterSendingAttempt(false, batchedEvents.getBatchId()); + callback.onFailure(FailureType.REJECTED_BY_COLLECTOR, false, eventsInRequest); + + } else { + LOGGER.error("BatchEmitter failed to send {} events: code: {}", eventsInRequest.size(), code); + eventsDeletedFromStorage = eventStore.cleanupAfterSendingAttempt(true, batchedEvents.getBatchId()); + + if (code == -1) { + callback.onFailure(FailureType.HTTP_CONNECTION_FAILURE, true, eventsInRequest); + } else { + callback.onFailure(FailureType.REJECTED_BY_COLLECTOR, true, eventsInRequest); + } + + if (!eventsDeletedFromStorage.isEmpty()) { + callback.onFailure(FailureType.TRACKER_STORAGE_FULL, false, eventsDeletedFromStorage); + } + + // exponentially increase retry backoff time after the first failure, up to the maximum wait time + if (!retryDelay.compareAndSet(0, 100)) { + retryDelay.updateAndGet(this::calculateRetryDelay); + } + } + } catch (Exception e) { + LOGGER.error("BatchEmitter event sending error: {}", e.getMessage()); + if (batchedEvents != null) { + eventsDeletedFromStorage = eventStore.cleanupAfterSendingAttempt(true, batchedEvents.getBatchId()); + callback.onFailure(FailureType.EMITTER_REQUEST_FAILURE, true, new ArrayList<>(batchedEvents.getPayloads())); + + if (!eventsDeletedFromStorage.isEmpty()) { + callback.onFailure(FailureType.TRACKER_STORAGE_FULL, false, eventsDeletedFromStorage); + } + } + } + }; + } + + /** + * Constructs the SelfDescribingJson to be sent to the endpoint + * + * @param events the event buffer + * @return the constructed POST payload + */ + private SelfDescribingJson getFinalPost(final List events) { + final List> toSendPayloads = new ArrayList<>(); + final String sentTimestamp = Long.toString(System.currentTimeMillis()); + + for (TrackerPayload payload : events) { + payload.add(Parameter.DEVICE_SENT_TIMESTAMP, sentTimestamp); + toSendPayloads.add(payload.getMap()); + } + + return new SelfDescribingJson(Constants.SCHEMA_PAYLOAD_DATA, toSendPayloads); + } + + private int calculateRetryDelay(int currentDelay) { + double newDelay; + double jitter = Math.random(); + int randomChoice = (Math.random() < 0.5) ? 0 : 1; + + switch (randomChoice) { + case 0: + newDelay = currentDelay * (2.0 + jitter); + break; + case 1: + newDelay = currentDelay * (2.0 - jitter); + break; + default: + newDelay = currentDelay; + } + return Math.min((int) newDelay, maximumRetryDelay); + } + + /** + * Attempt to send all remaining events, then shut down the ExecutorService. + * + *

+ * Implementation note: Be aware that calling `close()` + * has a side-effect of shutting down the Emitter ScheduledExecutorService. + */ + @Override + public void close() { + final long closeTimeout = 5; + isClosing = true; + + flushBuffer(); // Attempt to send all remaining events + + //Shutdown executor threadpool + if (executor != null) { + executor.shutdown(); + try { + if (!executor.awaitTermination(closeTimeout, TimeUnit.SECONDS)) { + executor.shutdownNow(); + if (!executor.awaitTermination(closeTimeout, TimeUnit.SECONDS)) + LOGGER.warn("Emitter executor did not terminate"); + } + } catch (final InterruptedException ie) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + } + + /** + * Copied from `Executors.defaultThreadFactory()`. + * The only change is the generated name prefix. + */ + static class EmitterThreadFactory implements ThreadFactory { + private static final AtomicInteger poolNumber = new AtomicInteger(1); + private final ThreadGroup group; + private final AtomicInteger threadNumber = new AtomicInteger(1); + private final String namePrefix; + + EmitterThreadFactory() { + SecurityManager securityManager = System.getSecurityManager(); + group = securityManager != null ? securityManager.getThreadGroup() : Thread.currentThread().getThreadGroup(); + namePrefix = "snowplow-emitter-pool-" + poolNumber.getAndIncrement() + "-request-thread-"; + } + + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(group, runnable, namePrefix + threadNumber.getAndIncrement(), 0L); + if (thread.isDaemon()) { + thread.setDaemon(false); + } + + if (thread.getPriority() != 5) { + thread.setPriority(5); + } + + return thread; + } + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchPayload.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchPayload.java new file mode 100644 index 00000000..7cc0187c --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchPayload.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.emitter; + +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +import java.util.List; + +/** + * A wrapper for a number of TrackerPayloads. + */ +public class BatchPayload { + + private final Long batchId; + private final List payloads; + + public BatchPayload(Long batchId, List payloads) { + this.batchId = batchId; + this.payloads = payloads; + } + + public Long getBatchId() { + return batchId; + } + + public List getPayloads() { + return payloads; + } + + public int size() { + return payloads.size(); + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/CLAUDE.md b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/CLAUDE.md new file mode 100644 index 00000000..bbb5ad22 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/CLAUDE.md @@ -0,0 +1,350 @@ +# Emitter Module - Snowplow Java Tracker + +## Module Overview + +The emitter module handles event transmission to Snowplow collectors. It provides batching, buffering, retry logic, and asynchronous event sending with configurable HTTP clients. The BatchEmitter is the primary implementation with sophisticated error handling and exponential backoff. + +## Core Components + +``` +Emitter (interface) +└── BatchEmitter # Main implementation with batching + ├── EventStore # Event persistence interface + │ └── InMemoryEventStore # Default in-memory storage + ├── BatchPayload # POST request wrapper + ├── EmitterCallback # Success/failure callbacks + └── FailureType # Failure categorization +``` + +## Emitter Architecture Principles + +### 1. Asynchronous Processing +All events are processed asynchronously: +```java +// ✅ Events queued, not sent immediately +emitter.add(payload); // Returns quickly +// Event sent later by executor + +// ❌ Don't expect synchronous sending +emitter.add(payload); +// Event may not be sent yet! +``` + +### 2. Batch Processing Pattern +Events are batched for efficiency: +```java +// ✅ Configure batch size +EmitterConfiguration config = new EmitterConfiguration() + .batchSize(25) // Send when 25 events buffered + .bufferCapacity(1000); // Max buffer size +``` + +### 3. Retry with Exponential Backoff +Failed requests retry with increasing delays: +```java +// ✅ Automatic retry handling +// Initial: 0ms delay +// First failure: 100ms delay +// Second failure: 200ms delay +// Max delay: 600000ms (10 min) +``` + +### 4. Thread Pool Management +Configurable thread pool for sending: +```java +// ✅ Configure thread count +EmitterConfiguration config = new EmitterConfiguration() + .threadCount(2); // Number of sender threads +``` + +## BatchEmitter Implementation + +### Constructor Pattern +```java +// ✅ Use configuration objects +BatchEmitter emitter = new BatchEmitter( + networkConfig, // URL and HTTP client + emitterConfig // Batching and threading +); + +// ❌ Don't use deprecated builder +BatchEmitter.builder().url(url).build(); +``` + +### Event Addition Flow +```java +// ✅ Standard flow +boolean success = emitter.add(payload); +if (!success) { + // Buffer full, event dropped +} +``` + +### Buffer Management +```java +// ✅ Force flush buffer +emitter.flushBuffer(); + +// ✅ Close and flush +emitter.close(); // Flushes remaining events +``` + +## EventStore Pattern + +### Interface Contract +```java +public interface EventStore { + boolean add(TrackerPayload payload); + boolean remove(TrackerPayload payload); + boolean removeAll(List payloads); + List getBuffer(); + long getSize(); +} +``` + +### InMemoryEventStore Implementation +```java +// ✅ Thread-safe implementation +public class InMemoryEventStore implements EventStore { + private final AtomicLong bufferSize = new AtomicLong(0); + private final ConcurrentLinkedDeque buffer; + private final long bufferCapacity; +} +``` + +## HTTP Client Configuration + +### Client Adapter Options +```java +// ✅ OkHttp (default) +HttpClientAdapter client = new OkHttpClientAdapter(url); + +// ✅ Apache HTTP +HttpClientAdapter client = new ApacheHttpClientAdapter(url); + +// ✅ Custom implementation +HttpClientAdapter custom = new CustomAdapter(); +networkConfig.httpClientAdapter(custom); +``` + +### Cookie Management +```java +// ✅ Cookie jar for network_userid +OkHttpClientWithCookieJarAdapter adapter = + new OkHttpClientWithCookieJarAdapter(url); +``` + +## Callback Pattern + +### EmitterCallback Interface +```java +// ✅ Implement callbacks +EmitterCallback callback = new EmitterCallback() { + @Override + public void onSuccess(List payloads) { + // Handle successful send + } + + @Override + public void onFailure(FailureType type, boolean willRetry, + List payloads) { + // Handle failure + } +}; +``` + +### Failure Types +```java +public enum FailureType { + REJECTED_BY_COLLECTOR, // 4xx responses + TRACKER_ISSUE, // 5xx or network errors + EMITTER_REQUEST_FAILURE // Client-side issues +} +``` + +## Custom Retry Logic + +### Status Code Configuration +```java +// ✅ Custom retry for status codes +Map customRetry = new HashMap<>(); +customRetry.put(403, false); // Don't retry 403 +customRetry.put(500, true); // Retry 500 + +EmitterConfiguration config = new EmitterConfiguration() + .customRetryForStatusCodes(customRetry); +``` + +## Request Building + +### GET Request Pattern +```java +// ✅ Single event GET request +String url = collectorUrl + "/i?" + payload.toString(); +``` + +### POST Request Pattern +```java +// ✅ Batch POST request +BatchPayload batch = new BatchPayload(); +batch.add(payload1); +batch.add(payload2); +String json = batch.toString(); +// POST to collectorUrl + "/com.snowplowanalytics.snowplow/tp2" +``` + +## Thread Safety Patterns + +### 1. Concurrent Buffer Access +```java +// ✅ Thread-safe operations +private final ConcurrentLinkedDeque buffer; +private final AtomicLong bufferSize; +``` + +### 2. Executor Management +```java +// ✅ Proper shutdown +@Override +public void close() { + isClosing = true; + flushBuffer(); + executor.shutdown(); + executor.awaitTermination(timeout, TimeUnit.SECONDS); +} +``` + +### 3. Atomic Retry Delay +```java +// ✅ Thread-safe retry counter +private final AtomicInteger retryDelay = new AtomicInteger(0); +``` + +## Testing Emitter Behavior + +### 1. Mock EventStore +```java +// ✅ Test with mock store +EventStore mockStore = mock(EventStore.class); +when(mockStore.getBuffer()).thenReturn(payloads); +``` + +### 2. MockWebServer Testing +```java +// ✅ Test HTTP interactions +MockWebServer server = new MockWebServer(); +server.enqueue(new MockResponse().setResponseCode(200)); +BatchEmitter emitter = new BatchEmitter( + new NetworkConfiguration(server.url("/").toString()), + new EmitterConfiguration() +); +``` + +### 3. Callback Testing +```java +// ✅ Verify callbacks +AtomicBoolean success = new AtomicBoolean(false); +EmitterCallback callback = new EmitterCallback() { + @Override + public void onSuccess(List payloads) { + success.set(true); + } +}; +``` + +## Common Pitfalls + +### 1. Synchronous Expectations +```java +// ❌ Wrong: Expecting immediate send +emitter.add(payload); +assert(eventSent); // May fail + +// ✅ Correct: Wait or flush +emitter.add(payload); +emitter.flushBuffer(); +Thread.sleep(100); +``` + +### 2. Ignoring Buffer Limits +```java +// ❌ Wrong: Not checking return value +emitter.add(payload); // Might be dropped + +// ✅ Correct: Check success +if (!emitter.add(payload)) { + // Handle dropped event +} +``` + +### 3. Resource Leaks +```java +// ❌ Wrong: Not closing emitter +BatchEmitter emitter = new BatchEmitter(...); +// Use emitter... +// Never closed! + +// ✅ Correct: Always close +try (BatchEmitter emitter = new BatchEmitter(...)) { + // Use emitter +} // Auto-closed +``` + +### 4. Improper Thread Count +```java +// ❌ Wrong: Too many threads +.threadCount(100) // Excessive + +// ✅ Correct: Reasonable count +.threadCount(2) // Good default +``` + +## Performance Considerations + +### Batch Size Tuning +- Small batches (1-10): Lower latency, more requests +- Medium batches (25-50): Balanced +- Large batches (100+): Higher latency, fewer requests + +### Buffer Capacity +- Set based on expected event volume +- Consider memory constraints +- Default: 10,000 events + +### Thread Count +- 1-2 threads for most applications +- More threads for high-volume scenarios +- Consider collector capacity + +## Adding Custom Emitters + +### Template for Custom Emitter +```java +public class CustomEmitter implements Emitter { + @Override + public boolean add(TrackerPayload payload) { + // Custom logic + return true; + } + + @Override + public void flushBuffer() { + // Send all buffered events + } + + @Override + public void close() { + // Cleanup resources + } +} +``` + +## Contributing to Emitter Module + +### Guidelines +1. Maintain thread safety in all operations +2. Implement proper resource cleanup in close() +3. Honor the EmitterCallback contract +4. Test retry logic with various failure scenarios +5. Document any custom retry strategies +6. Ensure buffer limits are respected \ No newline at end of file diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/Emitter.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/Emitter.java index ff8f911c..56f71fdc 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/Emitter.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/Emitter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. * * This program is licensed to you under the Apache License Version 2.0, * and you may not use this file except in compliance with the Apache License Version 2.0. @@ -10,47 +10,56 @@ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ - package com.snowplowanalytics.snowplow.tracker.emitter; -import com.snowplowanalytics.snowplow.tracker.core.emitter.HttpMethod; -import com.snowplowanalytics.snowplow.tracker.core.emitter.RequestCallback; +import java.util.List; + +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; -public class Emitter extends com.snowplowanalytics.snowplow.tracker.core.emitter.Emitter { +/** + * Emitter interface. + */ +public interface Emitter { + + /** + * Adds a payload to the buffer and checks whether + * we have reached the buffer limit yet. + * + * @param payload a payload to be emitted + * @return if the payload was added to the buffer + */ + boolean add(TrackerPayload payload); + + /** + * Customize the emitter batch size to any valid integer + * greater than zero. + * + * @param batchSize number of events to collect before + * sending + */ + void setBatchSize(int batchSize); /** - * Create an Emitter instance with a collector URL. - * @param URI The collector URL. Don't include "http://" - this is done automatically. + * This can be used to manually send all buffered events. */ - public Emitter(String URI) { - super(URI); - } + void flushBuffer(); /** - * Create an Emitter instance with a collector URL, and callback function. - * @param URI The collector URL. Don't include "http://" - this is done automatically. - * @param callback The callback function to handle success/failure cases when sending events. + * Gets the Emitter Batch Size + * + * @return the batch size */ - public Emitter(String URI, RequestCallback callback) { - super(URI, callback); - } + int getBatchSize(); /** - * Create an Emitter instance with a collector URL, - * @param URI The collector URL. Don't include "http://" - this is done automatically. - * @param httpMethod The HTTP request method. If GET, BufferOption is set to Instant. + * Returns the List of Payloads that are in the buffer. + * + * @return the buffer events */ - public Emitter(String URI, HttpMethod httpMethod) { - super(URI, httpMethod); - } + List getBuffer(); /** - * Create an Emitter instance with a collector URL and HttpMethod to send requests. - * @param URI The collector URL. Don't include "http://" - this is done automatically. - * @param httpMethod The HTTP request method. If GET, BufferOption is set to Instant. - * @param callback The callback function to handle success/failure cases when sending events. + * Safely shuts down the Emitter. */ - public Emitter(String URI, HttpMethod httpMethod, RequestCallback callback) { - super(URI, httpMethod, callback); - } + void close(); } diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/emitter/RequestMethod.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/EmitterCallback.java similarity index 51% rename from snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/emitter/RequestMethod.java rename to src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/EmitterCallback.java index 68e14096..6535b7f2 100644 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/emitter/RequestMethod.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/EmitterCallback.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. * * This program is licensed to you under the Apache License Version 2.0, * and you may not use this file except in compliance with the Apache License Version 2.0. @@ -10,20 +10,19 @@ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ +package com.snowplowanalytics.snowplow.tracker.emitter; -package com.snowplowanalytics.snowplow.tracker.core.emitter; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +import java.util.List; /** - * RequestMethod is used to choose how network requests should be sent. - * This can be used by setRequestMethod(RequestMethod option) to set accordingly. + * This interface allows the user to provide callbacks for when events are + * successfully sent to the event collector, or at other times when data loss + * may occur, specified using the FailureType enum. */ -public enum RequestMethod { - /** - * Requests are sent synchronously, so it should be used with caution. - */ - Synchronous, - /** - * Requests are sent asynchronously using a background thread. - */ - Asynchronous +public interface EmitterCallback { + void onSuccess(List payloads); + + void onFailure(FailureType failureType, boolean willRetry, List payloads); } diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/EventStore.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/EventStore.java new file mode 100644 index 00000000..f165bff8 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/EventStore.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.emitter; + +import java.util.List; + +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +/** + * EventStore interface. For buffering events in the Emitter. + */ +public interface EventStore { + + /** + * Add TrackerPayload to buffer. + * + * @param trackerPayload the payload to add + * @return success or not + */ + boolean addEvent(TrackerPayload trackerPayload); + + /** + * Remove some TrackerPayloads from the buffer. + * + * @param numberToGet how many payloads to get + * @return a BatchPayload wrapper + */ + BatchPayload getEventsBatch(int numberToGet); + + /** + * Get a copy of all the TrackerPayloads in the buffer. + * + * @return List of all the stored events + */ + List getAllEvents(); + + /** + * Finish processing events after a request has been made. + * + * @param needRetry if another attempt should be made to send the events + * @param batchId the ID of the batch of events + * @return list of TrackerPayloads + */ + List cleanupAfterSendingAttempt(boolean needRetry, long batchId); + + /** + * Get the current size of the buffer. + * + * @return number of events currently in the buffer + */ + int size(); +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/FailureType.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/FailureType.java new file mode 100644 index 00000000..ca9eb792 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/FailureType.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.emitter; + +/** + * The supported failure options for EmitterCallback. + */ +public enum FailureType { + /** + * A request status code other than 2xx is received. Payloads in the request + * may be automatically retried or not following this kind of failure, depending on the status code + * and the BatchEmitter configuration. + */ + REJECTED_BY_COLLECTOR, + + /** + * The InMemoryEventStore buffer is full. This could occur if the network connection + * to the event collector is down, causing payloads to accumulate in the buffer. + * This failure can occur either when the Tracker attempts to add new events to the BatchEmitter, + * or when events that need to be retried are returned to the buffer, removing newer events + * to make space if necessary. + */ + TRACKER_STORAGE_FULL, + + /** + * An exception or unsuccessful POST request in the HttpClientAdapter. + */ + HTTP_CONNECTION_FAILURE, + + /** + * An exception during POST request in BatchEmitter. + */ + EMITTER_REQUEST_FAILURE +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/InMemoryEventStore.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/InMemoryEventStore.java new file mode 100644 index 00000000..dc29c49d --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/InMemoryEventStore.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.emitter; + +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Buffers events (as TrackerPayloads) in memory for sending via the BatchEmitter. + * + * The TrackerPayloads are stored in a queue. When the BatchEmitter calls {@link #getEventsBatch(int)}, + * the chosen number of TrackerPayloads are removed from the queue. The batch is added to a map of payloads + * that are currently being sent, and wrapped as a BatchPayload. This BatchPayload wrapper is returned to the + * Emitter. + * + * If the POST request is successful, the payloads are deleted from the map. + * If not, they are removed from the map and reinserted into the queue to be sent again. + */ +public class InMemoryEventStore implements EventStore { + private static final Logger LOGGER = LoggerFactory.getLogger(InMemoryEventStore.class); + private static final int DEFAULT_BUFFER_SIZE = 10000; + private final AtomicLong batchId = new AtomicLong(1); + + private final LinkedBlockingDeque eventBuffer; + private final ConcurrentHashMap> eventsBeingSent = new ConcurrentHashMap<>(); + + /** + * Create a InMemoryEventStore object with custom queue capacity. The default is 10 000 events. + * @param bufferCapacity the maximum number of events to buffer at once + */ + public InMemoryEventStore(int bufferCapacity) { + eventBuffer = new LinkedBlockingDeque<>(bufferCapacity); + } + + /** + * Create a InMemoryEventStore object with default buffer size (10 000 events). + */ + public InMemoryEventStore() { + this(DEFAULT_BUFFER_SIZE); + } + + /** + * Add TrackerPayload to buffer. Returns false if the buffer was full. + * Note that the event is lost in this case. + * + * @param trackerPayload the payload to add + * @return success or not + */ + @Override + public boolean addEvent(TrackerPayload trackerPayload) { + return eventBuffer.offer(trackerPayload); + } + + /** + * Remove some TrackerPayloads from the buffer. They are wrapped as a BatchPayload to return, + * and also stored in a separate collection inside InMemoryEventStore until the result of their POST request is known. + * + * @param numberToGet how many payloads to get + * @return a BatchPayload wrapper, or null + */ + @Override + public BatchPayload getEventsBatch(int numberToGet) { + List eventsToSend = new ArrayList<>(); + + synchronized (eventBuffer) { + if (eventBuffer.size() < numberToGet) { + return null; + } + eventBuffer.drainTo(eventsToSend, numberToGet); + } + + // The batch of events is wrapped as a BatchPayload + // They're also added to the "pending" event buffer, the eventsBeingSent HashMap + BatchPayload batchedEvents = new BatchPayload(batchId.getAndIncrement(), eventsToSend); + eventsBeingSent.put(batchedEvents.getBatchId(), batchedEvents.getPayloads()); + return batchedEvents; + } + + /** + * Finish processing events after a request has been made. If the request was successful, + * the events are deleted from the InMemoryEventStore. If not, they are reinserted at the beginning + * of the buffer queue for another attempt. + * + * @param needRetry if true, move events back to the buffer instead of deleting + * @param batchId the ID of the batch of events + * @return newer TrackerPayloads deleted from the queue to make space for older payloads + */ + @Override + public List cleanupAfterSendingAttempt(boolean needRetry, long batchId) { + // Events that successfully sent are deleted from the pending buffer + List events = eventsBeingSent.remove(batchId); + List removedEvents = new ArrayList<>(); + + // Events that didn't send are inserted at the head of the eventBuffer + // for immediate resending. + if (needRetry) { + while (events.size() > 0) { + TrackerPayload payloadToReinsert = events.remove(0); + boolean result = eventBuffer.offerFirst(payloadToReinsert); + if (!result) { + LOGGER.error("Event buffer is full. Dropping newer payload to reinsert older payload"); + removedEvents.add(eventBuffer.removeLast()); + eventBuffer.offerFirst(payloadToReinsert); + } + } + } + return removedEvents; + } + + /** + * Get a copy of all the TrackerPayloads in the buffer. This does not include any events + * currently being sent by the BatchEmitter. + * + * @return List of all the stored events + */ + @Override + public List getAllEvents() { + TrackerPayload[] events = eventBuffer.toArray(new TrackerPayload[0]); + return Arrays.asList(events); + } + + /** + * Get the current size of the buffer. This does not include any events + * currently being sent by the BatchEmitter. + * + * @return number of events currently in the buffer + */ + @Override + public int size() { + return eventBuffer.size(); + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/AbstractEvent.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/AbstractEvent.java new file mode 100644 index 00000000..5731a6d5 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/AbstractEvent.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.events; + +// Java +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; + +// This library +import com.snowplowanalytics.snowplow.tracker.Subject; +import com.snowplowanalytics.snowplow.tracker.constants.Parameter; +import com.snowplowanalytics.snowplow.tracker.payload.Payload; +import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +/** + * Base AbstractEvent class which contains + * elements that can be set in all events. These are context, trueTimestamp, and Subject. + * + * Context is a list of custom SelfDescribingJson entities. + * TrueTimestamp is a user-defined timestamp. + * Subject is an event-specific Subject. Its fields will override those of the + * Tracker-associated Subject, if present. + */ +public abstract class AbstractEvent implements Event { + + protected final List context; + + /** + * The trueTimestamp may be null if none is set. + */ + protected Long trueTimestamp; + protected final Subject subject; + + public static abstract class Builder> { + + private List context = new LinkedList<>(); + protected Long trueTimestamp = null; + private Subject subject = null; + + protected abstract T self(); + + /** + * Adds a list of custom context entities. + * + * @param context the list of entities + * @return itself + */ + public T customContext(List context) { + this.context = context; + return self(); + } + + /** + * The true timestamp of that event (as determined by the user). + * + * @param timestamp the event timestamp as + * unix epoch + * @return itself + */ + public T trueTimestamp(Long timestamp) { + this.trueTimestamp = timestamp; + return self(); + } + + /** + * A custom Subject for the event. Its fields will override those of the + * Tracker-associated Subject, if present. + * + * @param subject the eventId + * @return itself + */ + public T subject(Subject subject) { + this.subject = subject; + return self(); + } + } + + private static class Builder2 extends Builder { + @Override + protected Builder2 self() { + return this; + } + } + + public static Builder builder() { + return new Builder2(); + } + + protected AbstractEvent(Builder builder) { + + // Precondition checks + Objects.requireNonNull(builder.context); + + this.context = builder.context; + this.trueTimestamp = builder.trueTimestamp; + this.subject = builder.subject; + } + + /** + * @return the events custom context + */ + @Override + public List getContext() { + return new ArrayList<>(this.context); + } + + /** + * @return the event's true timestamp. + */ + @Override + public Long getTrueTimestamp() { + return trueTimestamp; + } + + /** + * @return the event subject + */ + @Override + public Subject getSubject() { + return this.subject; + } + + /** + * @return the event payload + */ + @Override + public abstract Payload getPayload(); + + /** + * Adds the default parameters to a TrackerPayload object. + * + * @param payload the payload to add to. + * @return the TrackerPayload with appended values. + */ + protected TrackerPayload putTrueTimestamp(TrackerPayload payload) { + if (getTrueTimestamp() != null) { + payload.add(Parameter.TRUE_TIMESTAMP, Long.toString(getTrueTimestamp())); + } + return payload; + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/CLAUDE.md b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/CLAUDE.md new file mode 100644 index 00000000..c54d2433 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/CLAUDE.md @@ -0,0 +1,293 @@ +# Events Module - Snowplow Java Tracker + +## Module Overview + +The events module defines all trackable event types in the Snowplow Java Tracker. Each event type implements the Event interface and extends AbstractEvent, providing a consistent builder-based API for event creation and immutable event objects. + +## Event Type Hierarchy + +``` +Event (interface) +└── AbstractEvent (abstract base) + ├── PageView # Web page views + ├── Structured # Generic structured events + ├── SelfDescribing # Schema-based custom events + ├── ScreenView # Mobile/app screen views + ├── Timing # Performance timing events + ├── EcommerceTransaction # Purchase events (deprecated) + └── EcommerceTransactionItem # Line items (deprecated) +``` + +## Core Event Patterns + +### 1. Builder Pattern Mandatory +Every event MUST use the builder pattern: +```java +// ✅ Correct: Builder pattern +PageView event = PageView.builder() + .pageUrl("https://example.com") + .build(); + +// ❌ Wrong: Direct instantiation +new PageView("https://example.com"); +``` + +### 2. AbstractEvent Base Class +All events extend AbstractEvent for common fields: +```java +// ✅ Inherit common behavior +public class PageView extends AbstractEvent { + public static class Builder extends AbstractEvent.Builder { + // Event-specific fields + } +} +``` + +### 3. Self-Returning Builder Pattern +Builders must return self() for chaining: +```java +// ✅ Correct self() implementation +public static class Builder extends AbstractEvent.Builder { + @Override + protected Builder self() { + return this; + } +} +``` + +### 4. Payload Generation Pattern +Each event implements getPayload(): +```java +// ✅ Standard payload creation +@Override +public TrackerPayload getPayload() { + TrackerPayload payload = new TrackerPayload(); + payload.add(Parameter.EVENT, Constants.EVENT_PAGE_VIEW); + payload.add(Parameter.PAGE_URL, pageUrl); + return putTrueTimestamp(payload); +} +``` + +## Event-Specific Patterns + +### PageView Events +```java +// ✅ Complete PageView example +PageView pageView = PageView.builder() + .pageUrl("https://example.com") // Required + .pageTitle("Example Page") // Optional + .referrer("https://google.com") // Optional + .customContext(contexts) // From AbstractEvent + .trueTimestamp(timestamp) // From AbstractEvent + .subject(eventSubject) // From AbstractEvent + .build(); +``` + +### Structured Events +```java +// ✅ Structured event with all fields +Structured event = Structured.builder() + .category("video") // Required + .action("play") // Required + .label("tutorial") // Optional + .property("intro") // Optional + .value(1.5) // Optional + .build(); +``` + +### SelfDescribing Events +```java +// ✅ Schema-based custom event +SelfDescribing event = SelfDescribing.builder() + .eventData(new SelfDescribingJson( + "iglu:com.example/event/jsonschema/1-0-0", + eventDataMap + )) + .build(); +``` + +### EcommerceTransaction Pattern +```java +// ✅ Transaction with items +EcommerceTransaction transaction = EcommerceTransaction.builder() + .orderId("ORDER-123") + .totalValue(99.99) + .items(item1, item2) // Variadic items + .build(); +// Note: Generates multiple events when tracked +``` + +## Common Implementation Requirements + +### 1. Field Validation +```java +// ✅ Validate required fields in build() +public PageView build() { + Objects.requireNonNull(pageUrl, "pageUrl cannot be null"); + if (pageUrl.isEmpty()) { + throw new IllegalArgumentException("pageUrl cannot be empty"); + } + return new PageView(this); +} +``` + +### 2. Immutability Enforcement +```java +// ✅ Final fields, no setters +public class PageView extends AbstractEvent { + private final String pageUrl; + private final String pageTitle; + // No setters, only getters +} +``` + +### 3. Context Handling +```java +// ✅ Contexts are copied, not referenced +@Override +public List getContext() { + return new ArrayList<>(this.context); +} +``` + +### 4. Timestamp Management +```java +// ✅ True timestamp is optional +Long trueTimestamp = getTrueTimestamp(); +if (trueTimestamp != null) { + payload.add(Parameter.TRUE_TIMESTAMP, Long.toString(trueTimestamp)); +} +``` + +## Event Parameters Reference + +### Common Parameters (AbstractEvent) +- `customContext`: List of SelfDescribingJson contexts +- `trueTimestamp`: User-defined timestamp (milliseconds) +- `subject`: Event-specific Subject override + +### PageView Parameters +- `pageUrl` (required): URL of the page +- `pageTitle`: Title of the page +- `referrer`: Referring URL + +### Structured Parameters +- `category` (required): Event category +- `action` (required): Event action +- `label`: Event label +- `property`: Event property +- `value`: Numeric value + +### SelfDescribing Parameters +- `eventData` (required): SelfDescribingJson with schema and data + +## Testing Event Creation + +### 1. Test Required Fields +```java +// ✅ Test validation +@Test(expected = NullPointerException.class) +public void testMissingRequiredField() { + PageView.builder().build(); // Should throw +} +``` + +### 2. Test Optional Fields +```java +// ✅ Test with nulls +PageView event = PageView.builder() + .pageUrl("https://example.com") + .pageTitle(null) // Should work + .build(); +``` + +### 3. Test Event Payload +```java +// ✅ Verify payload contents +TrackerPayload payload = event.getPayload(); +assertEquals("pv", payload.getMap().get("e")); +assertEquals(url, payload.getMap().get("url")); +``` + +## Anti-Patterns to Avoid + +### 1. Mutable Events +```java +// ❌ Never add setters +public void setPageUrl(String url) { + this.pageUrl = url; +} +``` + +### 2. Public Constructors +```java +// ❌ Don't expose constructors +public PageView(String url) { + this.pageUrl = url; +} +``` + +### 3. Direct Field Access +```java +// ❌ Don't expose mutable fields +public List context; +``` + +### 4. Missing Validation +```java +// ❌ Don't skip validation +public Event build() { + return new PageView(this); // No checks +} +``` + +## Adding New Event Types + +### Template for New Event +```java +public class NewEvent extends AbstractEvent { + private final String requiredField; + private final String optionalField; + + public static class Builder extends AbstractEvent.Builder { + private String requiredField; + private String optionalField; + + @Override + protected Builder self() { return this; } + + public Builder requiredField(String value) { + this.requiredField = value; + return self(); + } + + public NewEvent build() { + Objects.requireNonNull(requiredField); + return new NewEvent(this); + } + } + + private NewEvent(Builder builder) { + super(builder); + this.requiredField = builder.requiredField; + this.optionalField = builder.optionalField; + } + + @Override + public TrackerPayload getPayload() { + TrackerPayload payload = new TrackerPayload(); + payload.add(Parameter.EVENT, "new"); + return putTrueTimestamp(payload); + } +} +``` + +## Contributing to Events Module + +### Guidelines +1. All new events MUST extend AbstractEvent +2. All new events MUST use the builder pattern +3. Required fields MUST be validated in build() +4. Events MUST be immutable after creation +5. Test both required and optional field scenarios +6. Document schema requirements for SelfDescribing events \ No newline at end of file diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/EcommerceTransaction.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/EcommerceTransaction.java new file mode 100644 index 00000000..f3df73d0 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/EcommerceTransaction.java @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.events; + +// Java +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +// This library +import com.snowplowanalytics.snowplow.tracker.constants.Parameter; +import com.snowplowanalytics.snowplow.tracker.constants.Constants; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +/** + * Constructs an EcommerceTransaction event object. + *

+ * Implementation note: EcommerceTransaction/EcommerceTransactionItem uses a legacy design. + * We aim to deprecate it eventually. We advise using SelfDescribing events instead, and attaching the items + * as entities. + * + * The specific items purchased in the transaction must be added as EcommerceTransactionItem objects. + * This event type is different from the others in that it will generate more than one tracked event. + * There will be one "transaction" ("tr") event, and one "transaction item" ("ti") event for every + * EcommerceTransactionItem included in the EcommerceTransaction. + * + * To link the "transaction" and "transaction item" events, we recommend using the same orderId for the + * EcommerceTransaction and all attached EcommerceTransactionItems. + * + * To use the Currency Conversion pipeline enrichment, the currency string must be + * a valid Open Exchange Rates value. + */ +public class EcommerceTransaction extends AbstractEvent { + + private final String orderId; + private final Double totalValue; + private final String affiliation; + private final Double taxValue; + private final Double shipping; + private final String city; + private final String state; + private final String country; + private final String currency; + private final List items; + + public static abstract class Builder> extends AbstractEvent.Builder { + + private String orderId; + private Double totalValue; + private String affiliation; + private Double taxValue; + private Double shipping; + private String city; + private String state; + private String country; + private String currency; + private List items; + + /** + * Required. + * + * @param orderId ID of the eCommerce transaction + * @return itself + */ + public T orderId(String orderId) { + this.orderId = orderId; + return self(); + } + + /** + * Required. + * + * @param totalValue Total transaction value + * @return itself + */ + public T totalValue(Double totalValue) { + this.totalValue = totalValue; + return self(); + } + + /** + * Optional. + * + * @param affiliation Transaction affiliation + * @return itself + */ + public T affiliation(String affiliation) { + this.affiliation = affiliation; + return self(); + } + + /** + * Optional. + * + * @param taxValue Transaction tax value + * @return itself + */ + public T taxValue(Double taxValue) { + this.taxValue = taxValue; + return self(); + } + + /** + * Optional. + * + * @param shipping Delivery cost charged + * @return itself + */ + public T shipping(Double shipping) { + this.shipping = shipping; + return self(); + } + + /** + * Optional. + * + * @param city Delivery address city + * @return itself + */ + public T city(String city) { + this.city = city; + return self(); + } + + /** + * Optional. + * + * @param state Delivery address state + * @return itself + */ + public T state(String state) { + this.state = state; + return self(); + } + + /** + * Optional. + * + * @param country Delivery address country + * @return itself + */ + public T country(String country) { + this.country = country; + return self(); + } + + /** + * Optional. + * + * @param currency The currency the price is expressed in + * @return itself + */ + public T currency(String currency) { + this.currency = currency; + return self(); + } + + /** + * Provide a list of EcommerceTransactionItems. + * An empty list is valid, but probably not very useful. + * + * @param items The items in the transaction + * @return itself + */ + public T items(List items) { + this.items = items; + return self(); + } + + /** + * Provide EcommerceTransactionItems directly, without explicitly adding them + * to a list beforehand. + * + * @param itemArgs The items as a varargs argument + * @return itself + */ + public T items(EcommerceTransactionItem... itemArgs) { + List items = new ArrayList<>(); + Collections.addAll(items, itemArgs); + this.items = items; + return self(); + } + + public EcommerceTransaction build() { + return new EcommerceTransaction(this); + } + } + + private static class Builder2 extends Builder { + @Override + protected Builder2 self() { + return this; + } + } + + public static Builder builder() { + return new Builder2(); + } + + protected EcommerceTransaction(Builder builder) { + super(builder); + + // Precondition checks + Objects.requireNonNull(builder.orderId); + Objects.requireNonNull(builder.totalValue); + Objects.requireNonNull(builder.items); + if (builder.orderId.isEmpty()) { + throw new IllegalArgumentException("orderId cannot be empty"); + } + + this.orderId = builder.orderId; + this.totalValue = builder.totalValue; + this.affiliation = builder.affiliation; + this.taxValue = builder.taxValue; + this.shipping = builder.shipping; + this.city = builder.city; + this.state = builder.state; + this.country = builder.country; + this.currency = builder.currency; + this.items = builder.items; + } + + /** + * Returns a TrackerPayload which can be passed to an Emitter. + * + * @return the payload to be sent. + */ + public TrackerPayload getPayload() { + TrackerPayload payload = new TrackerPayload(); + payload.add(Parameter.EVENT, Constants.EVENT_ECOMM); + payload.add(Parameter.TR_ID, this.orderId); + payload.add(Parameter.TR_TOTAL, Double.toString(this.totalValue)); + payload.add(Parameter.TR_AFFILIATION, this.affiliation); + payload.add(Parameter.TR_TAX, + this.taxValue != null ? Double.toString(this.taxValue) : null); + payload.add(Parameter.TR_SHIPPING, + this.shipping != null ? Double.toString(this.shipping) : null); + payload.add(Parameter.TR_CITY, this.city); + payload.add(Parameter.TR_STATE, this.state); + payload.add(Parameter.TR_COUNTRY, this.country); + payload.add(Parameter.TR_CURRENCY, this.currency); + return putTrueTimestamp(payload); + } + + /** + * The list of EcommerceTransactionItems passed with the event. + * + * @return the items. + */ + public List getItems() { + return this.items; + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/EcommerceTransactionItem.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/EcommerceTransactionItem.java new file mode 100644 index 00000000..ca334866 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/EcommerceTransactionItem.java @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.events; + +// This library +import com.snowplowanalytics.snowplow.tracker.constants.Parameter; +import com.snowplowanalytics.snowplow.tracker.constants.Constants; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +import java.util.Objects; + +/** + * Constructs an EcommerceTransactionItem object. + *

+ * Implementation note: EcommerceTransaction/EcommerceTransactionItem uses a legacy design. + * We aim to deprecate it eventually. We advise using SelfDescribing events instead, and attaching the items + * as entities. + * + * EcommerceTransactionItems were designed for attaching data about purchased items to a + * EcommerceTransaction event. They can technically be sent as events in their own right, but this is + * not supported. + * + * To link the "transaction" and "transaction item" events, we recommend using the same orderId for the + * EcommerceTransaction and all attached EcommerceTransactionItems. + * + * To use the Currency Conversion pipeline enrichment, the currency string must be + * a valid Open Exchange Rates value. + */ +public class EcommerceTransactionItem extends AbstractEvent { + + private final String itemId; + private final String sku; + private final Double price; + private final Integer quantity; + private final String name; + private final String category; + private final String currency; + + public static abstract class Builder> extends AbstractEvent.Builder { + + private String itemId; + private String sku; + private Double price; + private Integer quantity; + private String name; + private String category; + private String currency; + + /** + * Required. + * + * @param itemId Item ID - ideally the same as the EcommerceTransaction orderId + * @return itself + */ + public T itemId(String itemId) { + this.itemId = itemId; + return self(); + } + + /** + * Required. + * + * @param sku Item SKU + * @return itself + */ + public T sku(String sku) { + this.sku = sku; + return self(); + } + + /** + * Required. + * + * @param price Item price + * @return itself + */ + public T price(Double price) { + this.price = price; + return self(); + } + + /** + * Required. + * + * @param quantity Item quantity + * @return itself + */ + public T quantity(Integer quantity) { + this.quantity = quantity; + return self(); + } + + /** + * Optional. + * + * @param name Item name + * @return itself + */ + public T name(String name) { + this.name = name; + return self(); + } + + /** + * Optional. + * + * @param category Item category + * @return itself + */ + public T category(String category) { + this.category = category; + return self(); + } + + /** + * Optional. + * + * @param currency The currency the price is expressed in + * @return itself + */ + public T currency(String currency) { + this.currency = currency; + return self(); + } + + public EcommerceTransactionItem build() { + return new EcommerceTransactionItem(this); + } + } + + private static class Builder2 extends Builder { + @Override + protected Builder2 self() { + return this; + } + } + + public static Builder builder() { + return new Builder2(); + } + + protected EcommerceTransactionItem(Builder builder) { + super(builder); + + // Precondition checks + Objects.requireNonNull(builder.itemId); + Objects.requireNonNull(builder.sku); + Objects.requireNonNull(builder.price); + Objects.requireNonNull(builder.quantity); + if (builder.itemId.isEmpty()) { + throw new IllegalArgumentException("itemId cannot be empty"); + } + if (builder.sku.isEmpty()) { + throw new IllegalArgumentException("sku cannot be empty"); + } + + this.itemId = builder.itemId; + this.sku = builder.sku; + this.price = builder.price; + this.quantity = builder.quantity; + this.name = builder.name; + this.category = builder.category; + this.currency = builder.currency; + } + + /** + * Returns a TrackerPayload which can be passed to an Emitter. + * + * @return the payload to be sent. + */ + public TrackerPayload getPayload() { + TrackerPayload payload = new TrackerPayload(); + payload.add(Parameter.EVENT, Constants.EVENT_ECOMM_ITEM); + payload.add(Parameter.TI_ITEM_ID, this.itemId); + payload.add(Parameter.TI_ITEM_SKU, this.sku); + payload.add(Parameter.TI_ITEM_NAME, this.name); + payload.add(Parameter.TI_ITEM_CATEGORY, this.category); + payload.add(Parameter.TI_ITEM_PRICE, Double.toString(this.price)); + payload.add(Parameter.TI_ITEM_QUANTITY, Integer.toString(this.quantity)); + payload.add(Parameter.TI_ITEM_CURRENCY, this.currency); + return putTrueTimestamp(payload); + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/Event.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/Event.java new file mode 100644 index 00000000..8b66023d --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/Event.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.events; + +import java.util.List; + +import com.snowplowanalytics.snowplow.tracker.Subject; +import com.snowplowanalytics.snowplow.tracker.payload.Payload; +import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; + +/** + * The event interface + */ +public interface Event { + + /** + * @return the event's custom context + */ + List getContext(); + + /** + * @return the event's true timestamp + */ + Long getTrueTimestamp(); + + /** + * @return the event-associated Subject + */ + Subject getSubject(); + + /** + * @return the event payload + */ + Payload getPayload(); +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/PageView.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/PageView.java new file mode 100644 index 00000000..52482ef8 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/PageView.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.events; + +// This library +import com.snowplowanalytics.snowplow.tracker.constants.Parameter; +import com.snowplowanalytics.snowplow.tracker.constants.Constants; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +import java.util.Objects; + +/** + * Constructs a PageView event object. + * + * When tracked, generates a "pv" or "page_view" event. + */ +public class PageView extends AbstractEvent { + + private final String pageUrl; + private final String pageTitle; + private final String referrer; + + public static abstract class Builder> extends AbstractEvent.Builder { + + private String pageUrl; + private String pageTitle; + private String referrer; + + /** + * Required. + * + * @param pageUrl URL of the viewed page + * @return itself + */ + public T pageUrl(String pageUrl) { + this.pageUrl = pageUrl; + return self(); + } + + /** + * Optional. + * + * @param pageTitle Title of the viewed page + * @return itself + */ + public T pageTitle(String pageTitle) { + this.pageTitle = pageTitle; + return self(); + } + + /** + * Optional. + * + * @param referrer Referrer URL of the page + * @return itself + */ + public T referrer(String referrer) { + this.referrer = referrer; + return self(); + } + + public PageView build() { + return new PageView(this); + } + } + + private static class Builder2 extends Builder { + @Override + protected Builder2 self() { + return this; + } + } + + public static Builder builder() { + return new Builder2(); + } + + protected PageView(Builder builder) { + super(builder); + + // Precondition checks + Objects.requireNonNull(builder.pageUrl); + if (builder.pageUrl.isEmpty()) { + throw new IllegalArgumentException("pageUrl cannot be empty"); + } + + this.pageUrl = builder.pageUrl; + this.pageTitle = builder.pageTitle; + this.referrer = builder.referrer; + } + + /** + * Returns a TrackerPayload which can be passed to an Emitter. + * + * @return the payload to be sent. + */ + public TrackerPayload getPayload() { + TrackerPayload payload = new TrackerPayload(); + payload.add(Parameter.EVENT, Constants.EVENT_PAGE_VIEW); + payload.add(Parameter.PAGE_URL, this.pageUrl); + payload.add(Parameter.PAGE_TITLE, this.pageTitle); + payload.add(Parameter.PAGE_REFR, this.referrer); + return putTrueTimestamp(payload); + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/ScreenView.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/ScreenView.java new file mode 100644 index 00000000..648cc702 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/ScreenView.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.events; + +import com.snowplowanalytics.snowplow.tracker.constants.Parameter; +import com.snowplowanalytics.snowplow.tracker.constants.Constants; +import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; + +import java.util.LinkedHashMap; + +/** + * Constructs a ScreenView event object. + * + * When tracked, generates a SelfDescribing event (event type "ue"). + */ +public class ScreenView extends AbstractEvent { + + private final String name; + private final String id; + + public static abstract class Builder> extends AbstractEvent.Builder { + + private String name; + private String id; + + /** + * One of name or id is required. + * + * @param name The (human-readable) name of the screen view + * @return itself + */ + public T name(String name) { + this.name = name; + return self(); + } + + /** + * One of name or id is required. + * + * @param id Screen view ID + * @return itself + */ + public T id(String id) { + this.id = id; + return self(); + } + + public ScreenView build() { + return new ScreenView(this); + } + } + + private static class Builder2 extends Builder { + @Override + protected Builder2 self() { + return this; + } + } + + public static Builder builder() { + return new Builder2(); + } + + protected ScreenView(Builder builder) { + super(builder); + + // Precondition checks + if (builder.name == null || builder.id == null) { + throw new IllegalArgumentException(); + } + + this.name = builder.name; + this.id = builder.id; + } + + /** + * Return the payload wrapped into a SelfDescribingJson. When a ScreenView is tracked, + * the Tracker creates and tracks an SelfDescribing event from this SelfDescribingJson. + * + * @return the payload as a SelfDescribingJson. + */ + public SelfDescribingJson getPayload() { + LinkedHashMap payload = new LinkedHashMap<>(); + payload.put(Parameter.SV_ID, this.id); + payload.put(Parameter.SV_NAME, this.name); + return new SelfDescribingJson(Constants.SCHEMA_SCREEN_VIEW, payload); + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/SelfDescribing.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/SelfDescribing.java new file mode 100644 index 00000000..b515b7e6 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/SelfDescribing.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.events; + +// This library +import com.snowplowanalytics.snowplow.tracker.constants.Parameter; +import com.snowplowanalytics.snowplow.tracker.constants.Constants; +import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +import java.util.Objects; + +/** + * Constructs a SelfDescribing event object. + * + * This is a customisable event type which allows you to track anything describable + * by a JsonSchema. + * + * When tracked, generates a self-describing event (event type "ue"). + */ +public class SelfDescribing extends AbstractEvent { + + private final SelfDescribingJson eventData; + private boolean base64Encode; + + public static abstract class Builder> extends AbstractEvent.Builder { + + private SelfDescribingJson eventData; + + /** + * Required. + * + * @param selfDescribingJson The properties of the event. Has two fields: "data", containing the event properties, + * and "schema", identifying the schema against which the data is validated + * @return itself + */ + public T eventData(SelfDescribingJson selfDescribingJson) { + this.eventData = selfDescribingJson; + return self(); + } + + public SelfDescribing build() { + return new SelfDescribing(this); + } + } + + private static class Builder2 extends Builder { + @Override + protected Builder2 self() { + return this; + } + } + + public static Builder builder() { + return new Builder2(); + } + + protected SelfDescribing(Builder builder) { + super(builder); + + // Precondition checks + Objects.requireNonNull(builder.eventData); + + this.eventData = builder.eventData; + } + + /** + * @param base64Encode whether to base64Encode the event data + */ + public void setBase64Encode(boolean base64Encode) { + this.base64Encode = base64Encode; + } + + /** + * Returns a TrackerPayload which can be passed to an Emitter. + * + * @return the payload to be sent. + */ + public TrackerPayload getPayload() { + TrackerPayload payload = new TrackerPayload(); + SelfDescribingJson envelope = new SelfDescribingJson( + Constants.SCHEMA_SELF_DESCRIBING_EVENT, this.eventData.getMap()); + payload.add(Parameter.EVENT, Constants.EVENT_SELF_DESCRIBING); + payload.addMap(envelope.getMap(), this.base64Encode, + Parameter.SELF_DESCRIBING_ENCODED, Parameter.SELF_DESCRIBING); + return putTrueTimestamp(payload); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + SelfDescribing that = (SelfDescribing) o; + + if (base64Encode != that.base64Encode) return false; + return Objects.equals(eventData, that.eventData); + } + + @Override + public int hashCode() { + int result = eventData != null ? eventData.hashCode() : 0; + result = 31 * result + (base64Encode ? 1 : 0); + return result; + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/Structured.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/Structured.java new file mode 100644 index 00000000..299eb740 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/Structured.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.events; + +// This library +import com.snowplowanalytics.snowplow.tracker.constants.Parameter; +import com.snowplowanalytics.snowplow.tracker.constants.Constants; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +import java.util.Objects; + +/** + * Constructs a Structured event object. + * + * This event type is provided to be roughly equivalent to Google Analytics-style events. + * Note that it is not automatically clear what data should be placed in what field. + * To aid data quality and modeling, agree on business-wide definitions when designing + * your tracking strategy. + * + * We recommend using SelfDescribing - fully custom - events instead. + * + * When tracked, generates a "struct" or "se" event. + * + */ +public class Structured extends AbstractEvent { + + private final String category; + private final String action; + private final String label; + private final String property; + private final Double value; + + public static abstract class Builder> extends AbstractEvent.Builder { + + private String category; + private String action; + private String label; + private String property; + private Double value; + + /** + * Required. + * + * @param category Category of the event + * @return itself + */ + public T category(String category) { + this.category = category; + return self(); + } + + /** + * Required. + * + * @param action Describes what happened in the event + * @return itself + */ + public T action(String action) { + this.action = action; + return self(); + } + + /** + * Optional. + * + * @param label Refers to the object the action is performed on + * @return itself + */ + public T label(String label) { + this.label = label; + return self(); + } + + /** + * Optional. + * + * @param property Property associated with either the action or the object + * @return itself + */ + public T property(String property) { + this.property = property; + return self(); + } + + /** + * Optional. + * + * @param value A value associated with the user action + * @return itself + */ + public T value(Double value) { + this.value = value; + return self(); + } + + public Structured build() { + return new Structured(this); + } + } + + private static class Builder2 extends Builder { + @Override + protected Builder2 self() { + return this; + } + } + + public static Builder builder() { + return new Builder2(); + } + + protected Structured(Builder builder) { + super(builder); + + // Precondition checks + Objects.requireNonNull(builder.category); + Objects.requireNonNull(builder.action); + if (builder.category.isEmpty()) { + throw new IllegalArgumentException("category cannot be empty"); + } + if (builder.action.isEmpty()) { + throw new IllegalArgumentException("action cannot be empty"); + } + + this.category = builder.category; + this.action = builder.action; + this.label = builder.label; + this.property = builder.property; + this.value = builder.value; + } + + /** + * Returns a TrackerPayload which can be passed to an Emitter. + * + * @return the payload to be sent. + */ + public TrackerPayload getPayload() { + TrackerPayload payload = new TrackerPayload(); + payload.add(Parameter.EVENT, Constants.EVENT_STRUCTURED); + payload.add(Parameter.SE_CATEGORY, this.category); + payload.add(Parameter.SE_ACTION, this.action); + payload.add(Parameter.SE_LABEL, this.label); + payload.add(Parameter.SE_PROPERTY, this.property); + payload.add(Parameter.SE_VALUE, + this.value != null ? Double.toString(this.value) : null); + return putTrueTimestamp(payload); + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/Timing.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/Timing.java new file mode 100644 index 00000000..e152ff9a --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/Timing.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.events; + +// Java +import java.util.LinkedHashMap; +import java.util.Objects; + +// This library +import com.snowplowanalytics.snowplow.tracker.constants.Parameter; +import com.snowplowanalytics.snowplow.tracker.constants.Constants; +import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; + +/** + * Constructs a Timing event object. + * + * When tracked, generates a SelfDescribing event (event type "ue"). + */ +public class Timing extends AbstractEvent { + + private final String category; + private final String variable; + private final Integer timing; + private final String label; + + public static abstract class Builder> extends AbstractEvent.Builder { + + private String category; + private String variable; + private Integer timing; + private String label; + + /** + * Required. + * + * @param category The category of the timed event + * @return itself + */ + public T category(String category) { + this.category = category; + return self(); + } + + /** + * Required. + * + * @param variable Identify the timing being recorded + * @return itself + */ + public T variable(String variable) { + this.variable = variable; + return self(); + } + + /** + * Required. + * + * @param timing The number of milliseconds in elapsed time to report + * @return itself + */ + public T timing(Integer timing) { + this.timing = timing; + return self(); + } + + /** + * Optional. + * + * @param label Optional description of this timing + * @return itself + */ + public T label(String label) { + this.label = label; + return self(); + } + + public Timing build() { + return new Timing(this); + } + } + + private static class Builder2 extends Builder { + @Override + protected Builder2 self() { + return this; + } + } + + public static Builder builder() { + return new Builder2(); + } + + protected Timing(Builder builder) { + super(builder); + + // Precondition checks + Objects.requireNonNull(builder.category); + Objects.requireNonNull(builder.timing); + Objects.requireNonNull(builder.variable); + if (builder.category.isEmpty()) { + throw new IllegalArgumentException("category cannot be empty"); + } + if (builder.variable.isEmpty()) { + throw new IllegalArgumentException("variable cannot be empty"); + } + + this.category = builder.category; + this.variable = builder.variable; + this.label = builder.label; + this.timing = builder.timing; + } + + /** + * Return the payload wrapped into a SelfDescribingJson. When a Timing event is tracked, + * the Tracker creates and tracks a SelfDescribing event from this SelfDescribingJson. + * + * @return the payload as a SelfDescribingJson. + */ + public SelfDescribingJson getPayload() { + LinkedHashMap payload = new LinkedHashMap<>(); + payload.put(Parameter.UT_CATEGORY, this.category); + payload.put(Parameter.UT_LABEL, this.label); + payload.put(Parameter.UT_TIMING, this.timing); + payload.put(Parameter.UT_VARIABLE, this.variable); + return new SelfDescribingJson(Constants.SCHEMA_USER_TIMINGS, payload); + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/http/AbstractHttpClientAdapter.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/AbstractHttpClientAdapter.java new file mode 100644 index 00000000..58f586f6 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/AbstractHttpClientAdapter.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.http; + +import com.snowplowanalytics.snowplow.tracker.constants.Constants; +import com.snowplowanalytics.snowplow.tracker.Utils; +import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +/** + * Abstract HttpClient class. + */ +public abstract class AbstractHttpClientAdapter implements HttpClientAdapter { + + protected final String url; + + public AbstractHttpClientAdapter(String url) { + this.url = url.replaceFirst("/*$", ""); + } + + /** + * Returns the HttpClient URI + * + * @return the uri String + */ + @Override + public String getUrl() { + return this.url; + } + + /** + * Sends a payload via a POST request. + * + * @param payload the SelfDescribingJson to send + */ + @Override + public int post(SelfDescribingJson payload) { + String url = this.url + "/" + Constants.PROTOCOL_VENDOR + "/" + Constants.PROTOCOL_VERSION; + String body = payload.toString(); + return doPost(url, body); + } + + /** + * Sends a payload via a GET request. + * + * @param payload the TrackerPayload to send + */ + @Override + public int get(TrackerPayload payload) { + String url = this.url + "/i?" + Utils.mapToQueryString(payload.getMap()); + return doGet(url); + } + + /** + * Returns the HttpClient in use; it is up to the developer + * to cast it back to its original class. + * + * @return the http client + */ + public abstract Object getHttpClient(); + + /** + * Sends the SelfDescribingJson string containing + * the events as a POST request to the endpoint. + * + * @param url the URL to send to + * @param payload the event payload String + * @return the result of the send + */ + protected abstract int doPost(String url, String payload); + + /** + * Sends the Map of key-value pairs for the event + * as a GET request to the endpoint. + * + * @param url the URL to send + * @return the result of the send + */ + protected abstract int doGet(String url); +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/http/ApacheHttpClientAdapter.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/ApacheHttpClientAdapter.java new file mode 100644 index 00000000..de72604c --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/ApacheHttpClientAdapter.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.http; + +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.StringEntity; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.snowplowanalytics.snowplow.tracker.constants.Constants; + +import java.util.Objects; + +/** + * A HttpClient built using Apache to send events via + * GET or POST requests. + */ +public class ApacheHttpClientAdapter extends AbstractHttpClientAdapter { + + private static final Logger LOGGER = LoggerFactory.getLogger(ApacheHttpClientAdapter.class); + private CloseableHttpClient httpClient; + + public ApacheHttpClientAdapter(String url, CloseableHttpClient httpClient) { + super(url); + + // Precondition checks + Objects.requireNonNull(httpClient); + + this.httpClient = httpClient; + } + + /** + * Returns the HttpClient in use; it is up to the developer + * to cast it back to its original class. + * + * @return the http client + */ + public Object getHttpClient() { + return this.httpClient; + } + + /** + * Attempts to send a group of payloads with a + * GET request to the configured endpoint. + * + * @param url the URL send + * @return the HttpResponse for the Request + */ + public int doGet(String url) { + try { + HttpGet httpGet = new HttpGet(url); + return httpClient.execute(httpGet, response -> { + return response.getCode(); + }); + } catch (Exception e) { + LOGGER.error("ApacheHttpClient GET Request failed: {}", e.getMessage()); + return -1; + } + } + + /** + * Attempts to send a group of payloads with a + * POST request to the configured endpoint. + * + * @param url the URL to send to + * @param payload the payload to send + * @return the HttpResponse for the Request + */ + public int doPost(String url, String payload) { + try { + HttpPost httpPost = new HttpPost(url); + httpPost.addHeader("Content-Type", Constants.POST_CONTENT_TYPE); + StringEntity params = new StringEntity(payload, ContentType.APPLICATION_JSON); + httpPost.setEntity(params); + return httpClient.execute(httpPost, response -> { + return response.getCode(); + }); + } catch (Exception e) { + LOGGER.error("ApacheHttpClient POST Request failed: {}", e.getMessage()); + return -1; + } + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/http/CollectorCookie.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/CollectorCookie.java new file mode 100644 index 00000000..6439c6b0 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/CollectorCookie.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.http; + +import okhttp3.Cookie; + +import java.util.*; + +public class CollectorCookie { + private final Cookie cookie; + + static List decorateAll(Collection cookies) { + List collectorCookies = new ArrayList<>(cookies.size()); + for (Cookie cookie : cookies) { + collectorCookies.add(new CollectorCookie(cookie)); + } + return collectorCookies; + } + + CollectorCookie(Cookie cookie) { + this.cookie = cookie; + } + + public boolean isExpired() { + return cookie.expiresAt() < System.currentTimeMillis(); + } + + Cookie getCookie() { + return cookie; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof CollectorCookie)) return false; + CollectorCookie that = (CollectorCookie) other; + return that.cookie.name().equals(this.cookie.name()) + && that.cookie.domain().equals(this.cookie.domain()) + && that.cookie.path().equals(this.cookie.path()); + } + + @Override + public int hashCode() { + int hash = 17; + hash = 31 * hash + cookie.name().hashCode(); + hash = 31 * hash + cookie.domain().hashCode(); + hash = 31 * hash + cookie.path().hashCode(); + return hash; + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/http/CollectorCookieJar.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/CollectorCookieJar.java new file mode 100644 index 00000000..5f049039 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/CollectorCookieJar.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.http; + +import okhttp3.Cookie; +import okhttp3.HttpUrl; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class CollectorCookieJar implements okhttp3.CookieJar { + private static final Set cookies = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + @Override + public List loadForRequest(HttpUrl url) { + List cookiesToRemove = new ArrayList<>(); + List validCookies = new ArrayList<>(); + + for (CollectorCookie currentCookie : cookies) { + if (currentCookie.isExpired()) { + cookiesToRemove.add(currentCookie); + } else if (currentCookie.getCookie().matches(url)) { + validCookies.add(currentCookie.getCookie()); + } + } + + if (!cookiesToRemove.isEmpty()) { + removeAll(cookiesToRemove); + } + + return validCookies; + } + + @Override + public void saveFromResponse(HttpUrl httpUrl, List cookies) { + saveAll(cookies); + } + + public void clear() { + cookies.clear(); + } + + private void saveAll(Collection newCookies) { + for (CollectorCookie cookie : CollectorCookie.decorateAll(newCookies)) { + cookies.remove(cookie); + cookies.add(cookie); + } + + } + + private void removeAll(Collection cookiesToRemove) { + for (CollectorCookie cookie : cookiesToRemove) { + cookies.remove(cookie); + } + } +} + diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/http/HttpClientAdapter.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/HttpClientAdapter.java new file mode 100644 index 00000000..105d02b7 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/HttpClientAdapter.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.http; + +// This library +import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +/** + * Interface for all HttpClients + */ +public interface HttpClientAdapter { + + /** + * Sends a group of events compressed into a + * single SelfDescribingJson payload + * + * @param payload the final event payload + * @return status code + */ + int post(SelfDescribingJson payload); + + /** + * Sends a single TrackerPayload via a + * GET request + * + * @param payload the event payload + * @return status code + */ + int get(TrackerPayload payload); + + /** + * Returns the HttpClient URI + * + * @return the uri String + */ + String getUrl(); + + /** + * Returns the HttpClient in use; it is up to the developer + * to cast it back to its original class. + * + * @return the http client + */ + Object getHttpClient(); +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/http/OkHttpClientAdapter.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/OkHttpClientAdapter.java new file mode 100644 index 00000000..95df941b --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/OkHttpClientAdapter.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.http; + +// Java +import java.io.IOException; +import java.util.Objects; + +// SquareUp +import okhttp3.*; + +// Slf4j +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +// This library +import com.snowplowanalytics.snowplow.tracker.constants.Constants; + +/** + * A HttpClient built using OkHttp to send events via + * GET or POST requests. + */ +public class OkHttpClientAdapter extends AbstractHttpClientAdapter { + + private static final Logger LOGGER = LoggerFactory.getLogger(OkHttpClientAdapter.class); + private final MediaType JSON = MediaType.get(Constants.POST_CONTENT_TYPE); + private OkHttpClient httpClient; + + public OkHttpClientAdapter(String url, OkHttpClient httpClient) { + super(url); + + // Precondition checks + Objects.requireNonNull(httpClient); + + this.httpClient = httpClient; + } + + public OkHttpClientAdapter(String url) { + this(url, new OkHttpClient.Builder().build()); + } + + /** + * Returns the HttpClient in use; it is up to the developer + * to cast it back to its original class. + * + * @return the http client + */ + public Object getHttpClient() { + return this.httpClient; + } + + /** + * Attempts to send a group of payloads with a + * GET request to the configured endpoint. + * + * @param url the URL send + * @return the HttpResponse code for the Request or -1 if exception is caught + */ + public int doGet(String url) { + int returnValue = -1; + + Request request = new Request.Builder().url(url).build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + LOGGER.error("OkHttpClient GET Request failed: {}", response); + } + returnValue = response.code(); + } catch (IOException e) { + LOGGER.error("OkHttpClient GET Request failed: {}", e.getMessage()); + } + + return returnValue; + } + + + /** + * Attempts to send a group of payloads with a + * POST request to the configured endpoint. + * + * @param url the URL to send to + * @param payload the payload to send + * @return the HttpResponse code for the Request or -1 if exception is caught + */ + public int doPost(String url, String payload) { + int returnValue = -1; + + RequestBody body = RequestBody.create(payload, JSON); + Request request = new Request.Builder() + .url(url) + .addHeader("Content-Type", Constants.POST_CONTENT_TYPE) + .post(body) + .build(); + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + LOGGER.error("OkHttpClient POST Request failed: {}", response); + } + returnValue = response.code(); + } catch (IOException e) { + LOGGER.error("OkHttpClient POST Request failed: {}", e.getMessage()); + } + + return returnValue; + } +} diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/emitter/HttpMethod.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/OkHttpClientWithCookieJarAdapter.java similarity index 51% rename from snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/emitter/HttpMethod.java rename to src/main/java/com/snowplowanalytics/snowplow/tracker/http/OkHttpClientWithCookieJarAdapter.java index 70350b6f..a8d05d70 100644 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/emitter/HttpMethod.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/OkHttpClientWithCookieJarAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. + * Copyright (c) 2024-present Snowplow Analytics Ltd. All rights reserved. * * This program is licensed to you under the Apache License Version 2.0, * and you may not use this file except in compliance with the Apache License Version 2.0. @@ -10,19 +10,20 @@ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ +package com.snowplowanalytics.snowplow.tracker.http; -package com.snowplowanalytics.snowplow.tracker.core.emitter; +// SquareUp +import okhttp3.*; /** - * HttpMethod is used to set the request method for your Emitter (i.e. GET or POST requests). + * A HttpClient built using OkHttp to send events via GET or POST requests. + * The adapter is configured to use a CollectorCookieJar to store and send cookies set by the collector. + * The cookies are stored in memory. */ -public enum HttpMethod { - /** - * Each event is sent individually in separate GET requests. - */ - GET, - /** - * Events can be grouped together in a SchemaPayload and sent in one request if desired. - */ - POST +public class OkHttpClientWithCookieJarAdapter extends OkHttpClientAdapter { + + public OkHttpClientWithCookieJarAdapter(String url) { + super(url, new OkHttpClient.Builder().cookieJar(new CollectorCookieJar()).build()); + } + } diff --git a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/payload/Payload.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/Payload.java similarity index 54% rename from snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/payload/Payload.java rename to src/main/java/com/snowplowanalytics/snowplow/tracker/payload/Payload.java index eeba53a5..31389e46 100644 --- a/snowplow-java-tracker-core/src/main/java/com/snowplowanalytics/snowplow/tracker/core/payload/Payload.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/Payload.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. * * This program is licensed to you under the Apache License Version 2.0, * and you may not use this file except in compliance with the Apache License Version 2.0. @@ -10,71 +10,64 @@ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ +package com.snowplowanalytics.snowplow.tracker.payload; -package com.snowplowanalytics.snowplow.tracker.core.payload; - -// Java import java.util.Map; -// JSON -import com.fasterxml.jackson.databind.JsonNode; - /** - * Payload interface * The Payload is used to store all the parameters and configurations that are used * to send data via the HTTP GET/POST request. - * @version 0.5.0 - * @author Jonathan Almeida */ public interface Payload { /** - * Add a basic parameter. + * Add a key-value pair to the payload. + * + * It checks that neither the key nor value is null or empty. + * * @param key The parameter key * @param value The parameter value as a String */ - public void add(String key, String value); - - /** - * Add a basic parameter. - * @param key The parameter key - * @param value The parameter value - */ - public void add(String key, Object value); + void add(String key, String value); /** * Add all the mappings from the specified map. The effect is the equivalent to that of calling - * add(String key, Object value) for each mapping for each key. - * @param map Mappings to be stored in this map + * {@link #add(String, String)} for each key value pair. + * + * @param map Key-Value pairs to be stored in this payload */ - public void addMap(Map map); + void addMap(Map map); /** * Add a map to the Payload with a key dependent on the base 64 encoding option you choose using the * two keys provided. - * @param map Mapping to be stored - * @param base64_encoded The option you choose to encode the data - * @param type_encoded The key that would be set if the encoding option was set to true - * @param type_no_encoded They key that would be set if the encoding option was set to false + * + * @param map Map to be converted to a String and stored as a value + * @param base64Encoded The option you choose to encode the data + * @param typeEncoded The key that would be set if the encoding option was set to true + * @param typeNotEncoded They key that would be set if the encoding option was set to false */ - public void addMap(Map map, Boolean base64_encoded, String type_encoded, String type_no_encoded); + void addMap(Map map, boolean base64Encoded, String typeEncoded, String typeNotEncoded); /** * Returns the Payload as a HashMap. + * * @return A HashMap */ - public Map getMap(); + Map getMap(); /** - * Returns the Payload using Jackson JSON to return a JsonNode. - * @return A JsonNode + * Returns the byte size of a payload. + * + * @return A long representing the byte size of the payload. */ - public JsonNode getNode(); + long getByteSize(); /** * Returns the Payload as a string. This is essentially the toString from the ObjectNode used * to store the Payload. + * * @return A string value of the Payload. */ - public String toString(); + String toString(); } diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/SchemaPayload.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/SchemaPayload.java deleted file mode 100644 index e825f202..00000000 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/SchemaPayload.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ - -package com.snowplowanalytics.snowplow.tracker.payload; - -public class SchemaPayload - extends com.snowplowanalytics.snowplow.tracker.core.payload.SchemaPayload { -} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/SelfDescribingJson.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/SelfDescribingJson.java new file mode 100644 index 00000000..915c17d8 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/SelfDescribingJson.java @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.payload; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.snowplowanalytics.snowplow.tracker.Utils; +import com.snowplowanalytics.snowplow.tracker.constants.Parameter; + +/** + * Builds a SelfDescribingJson object. SelfDescribingJson must contain only two fields, schema and data. + * + * Schema is the JsonSchema path for this Json. Data is the data. + */ +public class SelfDescribingJson implements Payload { + + private static final Logger LOGGER = LoggerFactory.getLogger(SelfDescribingJson.class); + private final LinkedHashMap payload = new LinkedHashMap<>(); + + /** + * Creates a SelfDescribingJson with only a Schema + * String and an empty data map. Data can be added later using setData() methods. + * + * @param schema the schema string + */ + public SelfDescribingJson(String schema) { + this(schema, new LinkedHashMap()); + } + + /** + * Creates a SelfDescribingJson with a Schema and a + * TrackerPayload object. + * + * Note that TrackerPayload objects are initialised with an eventId UUID and + * timestamp (deviceCreatedTimestamp), as they are the basis for sending events. + * Therefore, your SelfDescribingJson data will contain the keys "eid" and "dtm". + * This is unlikely to be what you want. + * + * @param schema the schema string + * @param data a TrackerPayload object to be embedded as + * the data + */ + public SelfDescribingJson(String schema, TrackerPayload data) { + setSchema(schema); + setData(data); + } + + /** + * Creates a SelfDescribingJson with a Schema and a + * SelfDescribingJson object. This can be used to + * nest SDJs inside each other. + * + * @param schema the schema string + * @param data a SelfDescribingJson object to be embedded as + * the data + */ + public SelfDescribingJson(String schema, SelfDescribingJson data) { + setSchema(schema); + setData(data); + } + + /** + * Creates a SelfDescribingJson with a Schema and a + * data object. + * + * @param schema the schema string + * @param data an object to attempt to embed as data + */ + public SelfDescribingJson(String schema, Object data) { + setSchema(schema); + setData(data); + } + + /** + * Sets the Schema for the SelfDescribingJson + * + * @param schema a valid schema string + * @return this SelfDescribingJson + */ + public SelfDescribingJson setSchema(String schema) { + Objects.requireNonNull(schema, "schema cannot be null"); + if (schema.isEmpty()) { + throw new IllegalArgumentException("schema cannot be empty"); + } + + payload.put(Parameter.SCHEMA, schema); + return this; + } + + /** + * Adds data to the SelfDescribingJson from a TrackerPayload object. + * + * Note that TrackerPayload objects are initialised with an eventId UUID and + * timestamp (deviceCreatedTimestamp), as they are the basis for sending events. + * Therefore, your SelfDescribingJson data will contain the keys "eid" and "dtm". + * This is unlikely to be what you want. + * + * @param data the data to be added to the SelfDescribingJson + * @return this SelfDescribingJson + */ + public SelfDescribingJson setData(TrackerPayload data) { + if (data == null) { + return this; + } + payload.put(Parameter.DATA, data.getMap()); + return this; + } + + /** + * Adds data to the SelfDescribingJson + * + * @param data the data to be added to the SelfDescribingJson + * @return this SelfDescribingJson + */ + public SelfDescribingJson setData(Object data) { + if (data == null) { + return this; + } + payload.put(Parameter.DATA, data); + return this; + } + + /** + * Allows us to add data from one SelfDescribingJson into another + * without copying over the Schema. + * + * @param data the payload to add to the SelfDescribingJson + * @return this SelfDescribingJson + */ + public SelfDescribingJson setData(SelfDescribingJson data) { + if (payload == null) { + return this; + } + payload.put(Parameter.DATA, data.getMap()); + return this; + } + + @Deprecated + @Override + public void add(String key, String value) { + LOGGER.info("Payload: add(String, String) method called - Doing nothing."); + } + + @Deprecated + @Override + public void addMap(Map map) { + LOGGER.info("Payload: addMap(Map) method called - Doing nothing."); + } + + @Deprecated + @Override + public void addMap(Map map, boolean base64Encoded, String typeEncoded, String typeNotEncoded) { + LOGGER.info("Payload: addMap(Map, boolean, String, String) method called - Doing nothing."); + } + + /** + * Returns the Payload as a Map. + * + * @return A Map of all the key-value entries + */ + @Override + public Map getMap() { + return payload; + } + + /** + * Returns the byte size of a payload. + * + * @return A long representing the byte size of the payload. + */ + @Override + public long getByteSize() { + return Utils.getUTF8Length(toString()); + } + + /** + * Returns the Payload as a string. This is essentially the toString from the ObjectNode used + * to store the Payload. + * + * @return A string value of the Payload. + */ + @Override + public String toString() { + return Utils.mapToJSONString(payload); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + SelfDescribingJson that = (SelfDescribingJson) o; + + return payload.equals(that.payload); + } + + @Override + public int hashCode() { + return payload.hashCode(); + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/TrackerParameters.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/TrackerParameters.java new file mode 100644 index 00000000..eddbfa2f --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/TrackerParameters.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.payload; + +import com.snowplowanalytics.snowplow.tracker.DevicePlatform; + +/** + * A wrapper for Tracker properties. + */ +public class TrackerParameters { + + private final String trackerVersion; + private final String appId; + private final DevicePlatform platform; + private final String namespace; + private final boolean base64Encoded; + + public TrackerParameters(String appId, DevicePlatform platform, String namespace, String trackerVersion, + boolean base64Encoded) { + this.appId = appId; + this.platform = platform; + this.namespace = namespace; + this.trackerVersion = trackerVersion; + this.base64Encoded = base64Encoded; + } + + public boolean getBase64Encoded() { + return base64Encoded; + } + + public String getTrackerVersion() { + return trackerVersion; + } + + public String getNamespace() { + return namespace; + } + + public String getAppId() { + return appId; + } + + public DevicePlatform getPlatform() { + return platform; + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/TrackerPayload.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/TrackerPayload.java index 0ca4ccbf..089f4346 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/TrackerPayload.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/TrackerPayload.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014 Snowplow Analytics Ltd. All rights reserved. + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. * * This program is licensed to you under the Apache License Version 2.0, * and you may not use this file except in compliance with the Apache License Version 2.0. @@ -10,9 +10,145 @@ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ - package com.snowplowanalytics.snowplow.tracker.payload; -public class TrackerPayload - extends com.snowplowanalytics.snowplow.tracker.core.payload.TrackerPayload { +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.snowplowanalytics.snowplow.tracker.constants.Parameter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.snowplowanalytics.snowplow.tracker.Utils; + +/** + * A TrackerPayload stores a map of key - pair values. + * + * When the Emitter attempts to send a TrackerPayload, these pairs are extracted + * and added to the HTTP request (via a SelfDescribingJson). + * The deviceSentTimestamp ("stm") is added at that point. + * + * EventId and deviceCreatedTimestamp are added to the internal map at + * TrackerPayload initialization. + */ +public class TrackerPayload implements Payload { + + private static final Logger LOGGER = LoggerFactory.getLogger(TrackerPayload.class); + protected final Map payload = new LinkedHashMap<>(); + private final String eventId; + private final Long deviceCreatedTimestamp; + + + public TrackerPayload() { + eventId = Utils.getEventId(); + deviceCreatedTimestamp = System.currentTimeMillis(); + + add(Parameter.EID, eventId); + add(Parameter.DEVICE_CREATED_TIMESTAMP, Long.toString(deviceCreatedTimestamp)); + } + + public String getEventId() { + return eventId; + } + + public Long getDeviceCreatedTimestamp() { + return deviceCreatedTimestamp; + } + + /** + * Add a key-value pair to the payload. + * Checks that neither the key nor the value are null or empty. + * + * @param key The parameter key + * @param value The parameter value as a String + */ + @Override + public void add(final String key, final String value) { + if (key == null || key.isEmpty()) { + LOGGER.error("Null or empty key detected"); + return; + } + if (value == null || value.isEmpty()) { + LOGGER.debug("Null or empty value detected: {}->{}", key, value); + return; + } + LOGGER.debug("Adding new kv pair: {}->{}", key, value); + payload.put(key, value); + } + + /** + * Add all the mappings from the specified map. The effect is the equivalent to + * that of calling {@link #add(String, String)} for each key value pair. + * + * @param map Key-Value pairs to be stored in this payload + */ + @Override + public void addMap(final Map map) { + if (map == null) { + LOGGER.debug("Map passed in is null, returning without adding map."); + return; + } + LOGGER.debug("Adding new map: {}", map); + for (final Map.Entry entry : map.entrySet()) { + add(entry.getKey(), entry.getValue()); + } + } + + /** + * Add a map to the Payload with a key dependent on the base 64 encoding option + * you choose using the two keys provided. + * + * @param map Map to be converted to a String and stored as a value + * @param base64Encoded The option you choose to encode the data + * @param typeEncoded The key that would be set if the encoding option was set to true + * @param typeNotEncoded They key that would be set if the encoding option was set to false + */ + @Override + public void addMap(final Map map, final boolean base64Encoded, final String typeEncoded, final String typeNotEncoded) { + if (map == null) { + LOGGER.debug("Map passed in is null, returning nothing."); + return; + } + + final String mapString = Utils.mapToJSONString(map); + LOGGER.debug("Adding new map: {}", map); + + if (base64Encoded) { + add(typeEncoded, Utils.base64Encode(mapString, StandardCharsets.UTF_8)); + } else { + add(typeNotEncoded, mapString); + } + } + + /** + * Returns the Payload as a Map. + * + * @return A Map of all the key-value entries + */ + @Override + public Map getMap() { + return payload; + } + + /** + * Returns the byte size of a payload. + * + * @return A long representing the byte size of the payload. + */ + @Override + public long getByteSize() { + return Utils.getUTF8Length(toString()); + } + + /** + * Returns the Payload as a string. This is essentially the toString from the + * ObjectNode used to store the Payload. + * + * @return A string value of the Payload. + */ + @Override + public String toString() { + return Utils.mapToJSONString(payload); + } } diff --git a/src/test/java/CLAUDE.md b/src/test/java/CLAUDE.md new file mode 100644 index 00000000..341be053 --- /dev/null +++ b/src/test/java/CLAUDE.md @@ -0,0 +1,371 @@ +# Testing Guide - Snowplow Java Tracker + +## Testing Overview + +The test suite uses JUnit 4 with JUnit 5 Vintage Engine for backward compatibility. Tests focus on unit testing individual components with extensive use of mocking for external dependencies like HTTP servers. + +## Test Organization + +``` +src/test/java/com/snowplowanalytics/snowplow/tracker/ +├── TrackerTest.java # Core tracker functionality +├── SnowplowTest.java # Factory and registry +├── SubjectTest.java # Subject data management +├── UtilsTest.java # Utility functions +├── emitter/ # Emitter layer tests +├── events/ # Event type tests +├── payload/ # Payload serialization tests +└── http/ # HTTP client tests +``` + +## Core Testing Patterns + +### 1. Mock Emitter Pattern +Essential for testing tracker behavior without network calls: +```java +// ✅ Standard MockEmitter +public static class MockEmitter implements Emitter { + public List eventList = new ArrayList<>(); + + @Override + public boolean add(TrackerPayload payload) { + eventList.add(payload); + return true; + } +} +``` + +### 2. Test Setup Pattern +Consistent test initialization: +```java +// ✅ Standard setUp +@Before +public void setUp() { + mockEmitter = new MockEmitter(); + TrackerConfiguration config = new TrackerConfiguration("AF003", "cloudfront") + .base64Encoded(false); + tracker = new Tracker(config, mockEmitter); +} +``` + +### 3. MockWebServer Pattern +For testing HTTP interactions: +```java +// ✅ HTTP testing setup +MockWebServer server = new MockWebServer(); +server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("ok")); +String url = server.url("/").toString(); +``` + +### 4. Assertion Patterns +Verify event payloads correctly: +```java +// ✅ Payload verification +TrackerPayload payload = mockEmitter.eventList.get(0); +Map map = payload.getMap(); +assertEquals("pv", map.get("e")); // Event type +assertEquals(url, map.get("url")); // Page URL +assertNotNull(map.get("eid")); // Event ID +``` + +## Event Testing Patterns + +### 1. Builder Validation Tests +```java +// ✅ Test required fields +@Test(expected = NullPointerException.class) +public void testMissingRequiredField() { + PageView.builder().build(); // Missing pageUrl +} +``` + +### 2. Optional Field Tests +```java +// ✅ Test optional fields +@Test +public void testOptionalFields() { + PageView event = PageView.builder() + .pageUrl("https://example.com") + .pageTitle(null) // Should work + .build(); + assertNotNull(event); +} +``` + +### 3. Context Testing +```java +// ✅ Test custom contexts +@Test +public void testCustomContext() { + List contexts = singletonList( + new SelfDescribingJson("schema", + Collections.singletonMap("key", "value")) + ); + PageView event = PageView.builder() + .pageUrl("https://example.com") + .customContext(contexts) + .build(); +} +``` + +## Emitter Testing Patterns + +### 1. Batch Processing Tests +```java +// ✅ Test batching behavior +@Test +public void testBatchSize() throws InterruptedException { + BatchEmitter emitter = new BatchEmitter( + networkConfig, + new EmitterConfiguration().batchSize(2) + ); + emitter.add(payload1); + emitter.add(payload2); // Should trigger send + Thread.sleep(500); + // Verify batch sent +} +``` + +### 2. Retry Logic Tests +```java +// ✅ Test retry on failure +@Test +public void testRetryLogic() { + server.enqueue(new MockResponse().setResponseCode(500)); + server.enqueue(new MockResponse().setResponseCode(200)); + // Add event and verify retry +} +``` + +### 3. Callback Tests +```java +// ✅ Test callbacks +@Test +public void testSuccessCallback() { + AtomicBoolean called = new AtomicBoolean(false); + EmitterCallback callback = new EmitterCallback() { + @Override + public void onSuccess(List payloads) { + called.set(true); + } + }; + // Verify callback invoked +} +``` + +## Payload Testing Patterns + +### 1. Serialization Tests +```java +// ✅ Test JSON serialization +@Test +public void testJsonSerialization() { + SelfDescribingJson json = new SelfDescribingJson( + "schema", + Collections.singletonMap("key", "value") + ); + String result = json.toString(); + assertTrue(result.contains("\"schema\":\"schema\"")); +} +``` + +### 2. Base64 Encoding Tests +```java +// ✅ Test encoding +@Test +public void testBase64Encoding() { + TrackerConfiguration config = new TrackerConfiguration("ns", "app") + .base64Encoded(true); + // Verify contexts are base64 encoded +} +``` + +### 3. Size Calculation Tests +```java +// ✅ Test payload size +@Test +public void testPayloadSize() { + TrackerPayload payload = new TrackerPayload(); + payload.add("key", "value"); + assertTrue(payload.getByteSize() > 0); +} +``` + +## Subject Testing Patterns + +### 1. Subject Merging Tests +```java +// ✅ Test subject override +@Test +public void testSubjectOverride() { + Subject trackerSubject = new Subject(); + trackerSubject.setUserId("tracker-user"); + + Subject eventSubject = new Subject(); + eventSubject.setUserId("event-user"); + + // Event subject should override +} +``` + +### 2. Platform Detection Tests +```java +// ✅ Test platform setting +@Test +public void testPlatformDetection() { + Subject subject = new Subject(); + subject.setPlatform(DevicePlatform.Mobile); + assertEquals("mob", subject.getSubject().get("p")); +} +``` + +## Thread Safety Testing + +### 1. Concurrent Access Tests +```java +// ✅ Test thread safety +@Test +public void testConcurrentAccess() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(10); + for (int i = 0; i < 10; i++) { + new Thread(() -> { + tracker.track(event); + latch.countDown(); + }).start(); + } + latch.await(); + // Verify all events tracked +} +``` + +### 2. Buffer Overflow Tests +```java +// ✅ Test buffer limits +@Test +public void testBufferOverflow() { + EmitterConfiguration config = new EmitterConfiguration() + .bufferCapacity(2); + // Add 3 events, verify one dropped +} +``` + +## Test Utilities + +### 1. Event ID Validation +```java +// ✅ Validate UUID format +private boolean isValidUUID(String id) { + try { + UUID.fromString(id); + return true; + } catch (Exception e) { + return false; + } +} +``` + +### 2. Timestamp Validation +```java +// ✅ Validate timestamp +private boolean isValidTimestamp(String ts) { + try { + long timestamp = Long.parseLong(ts); + return timestamp > 0; + } catch (Exception e) { + return false; + } +} +``` + +### 3. JSON Validation +```java +// ✅ Validate JSON structure +private boolean isValidJson(String json) { + try { + new ObjectMapper().readTree(json); + return true; + } catch (Exception e) { + return false; + } +} +``` + +## Common Test Anti-Patterns + +### 1. Real Network Calls +```java +// ❌ Don't use real endpoints +BatchEmitter emitter = new BatchEmitter( + new NetworkConfiguration("https://real-collector.com"), + config +); + +// ✅ Use MockWebServer +MockWebServer server = new MockWebServer(); +``` + +### 2. Sleep Without Reason +```java +// ❌ Arbitrary sleep +Thread.sleep(5000); // Why? + +// ✅ Sleep with purpose +Thread.sleep(100); // Allow async operation +``` + +### 3. Missing Cleanup +```java +// ❌ Resources not cleaned +MockWebServer server = new MockWebServer(); +// Never shutdown + +// ✅ Proper cleanup +@After +public void tearDown() throws IOException { + server.shutdown(); +} +``` + +### 4. Overly Complex Mocks +```java +// ❌ Complex mock setup +when(mock.method1()).thenReturn(x); +when(mock.method2()).thenReturn(y); +// 20 more lines... + +// ✅ Simple test double +class SimpleEmitter implements Emitter { + // Minimal implementation +} +``` + +## Test Execution + +### Running Tests +```bash +# Run all tests +./gradlew test + +# Run specific test class +./gradlew test --tests TrackerTest + +# Run with coverage +./gradlew test jacocoTestReport +``` + +### Test Categories +- **Unit Tests**: Individual component testing +- **Integration Tests**: Component interaction +- **Concurrency Tests**: Thread safety verification +- **Performance Tests**: Not in main suite + +## Contributing Test Guidelines + +1. **Test Naming**: Use descriptive names (testPageViewWithAllFields) +2. **One Assertion Per Test**: Keep tests focused +3. **Mock External Dependencies**: Never make real network calls +4. **Test Edge Cases**: Null values, empty strings, limits +5. **Document Complex Tests**: Add comments for non-obvious logic +6. **Clean Up Resources**: Always close/shutdown in @After \ No newline at end of file diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/SnowplowTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/SnowplowTest.java new file mode 100644 index 00000000..b245f590 --- /dev/null +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/SnowplowTest.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker; + +import com.snowplowanalytics.snowplow.tracker.configuration.NetworkConfiguration; +import com.snowplowanalytics.snowplow.tracker.configuration.TrackerConfiguration; +import com.snowplowanalytics.snowplow.tracker.emitter.BatchEmitter; +import org.junit.After; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class SnowplowTest { + + @After + public void cleanUp(){ + Snowplow.reset(); + } + + @Test + public void createsAndRetrievesATracker() { + assertTrue(Snowplow.getInstancedTrackerNamespaces().isEmpty()); + + Tracker tracker = Snowplow.createTracker("namespace", "appId", "http://endpoint"); + Tracker retrievedTracker = Snowplow.getTracker("namespace"); + + assertFalse(Snowplow.getInstancedTrackerNamespaces().isEmpty()); + assertEquals(tracker, retrievedTracker); + assertEquals("namespace", tracker.getNamespace()); + assertEquals("appId", tracker.getAppId()); + assertTrue(tracker.getBase64Encoded()); + } + + @Test + public void preventsDuplicateNamespaces() { + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + Snowplow.createTracker("namespace", "appId", "http://endpoint"); + Snowplow.createTracker("namespace", "appId2", "http://collector"); + }); + + assertEquals("Tracker with this namespace already exists.", exception.getMessage()); + } + + @Test + public void deletesStoredTracker() { + Snowplow.createTracker("namespace", "appId", "http://endpoint"); + boolean result = Snowplow.removeTracker("namespace"); + assertTrue(result); + + Tracker tracker = Snowplow.createTracker("namespace2", "appId", "http://endpoint"); + boolean result2 = Snowplow.removeTracker(tracker); + assertTrue(result2); + } + + @Test + public void doesNotDeleteUnregisteredTracker() { + BatchEmitter emitter = new BatchEmitter(new NetworkConfiguration("http://collector")); + Tracker tracker = new Tracker(new TrackerConfiguration("namespace", "appId"), emitter); + + boolean result = Snowplow.removeTracker(tracker); + assertFalse(result); + + boolean result2 = Snowplow.removeTracker("not registered"); + assertFalse(result2); + } + + @Test + public void setsDefaultTrackerFromObject() { + assertNull(Snowplow.getDefaultTracker()); + + Tracker tracker = Snowplow.createTracker("namespace", "appId", "http://endpoint"); + assertEquals(tracker, Snowplow.getDefaultTracker()); + + Tracker tracker2 = Snowplow.createTracker("namespace2", "appId", "http://endpoint"); + // The first tracker is still the default + assertEquals(tracker, Snowplow.getDefaultTracker()); + + Snowplow.setDefaultTracker(tracker2); + assertEquals(tracker2, Snowplow.getDefaultTracker()); + assertEquals(2, Snowplow.getInstancedTrackerNamespaces().size()); + } + + @Test + public void setsDefaultTrackerFromNamespace() { + assertNull(Snowplow.getDefaultTracker()); + + Snowplow.createTracker("namespace", "appId", "http://endpoint"); + Tracker tracker2 = Snowplow.createTracker("namespace2", "appId", "http://endpoint"); + + boolean result = Snowplow.setDefaultTracker("namespace2"); + assertTrue(result); + assertEquals(tracker2, Snowplow.getDefaultTracker()); + } + + @Test + public void registersATrackerMadeWithoutSnowplowClass() { + BatchEmitter emitter = new BatchEmitter(new NetworkConfiguration("http://collector")); + Tracker tracker = new Tracker(new TrackerConfiguration("namespace", "appId"), emitter); + + Snowplow.registerTracker(tracker); + + assertEquals(tracker, Snowplow.getDefaultTracker()); + assertEquals(1, Snowplow.getInstancedTrackerNamespaces().size()); + } + + @Test + public void settingNewDefaultTrackerRegistersIt() { + BatchEmitter emitter = new BatchEmitter(new NetworkConfiguration("http://collector")); + Tracker tracker = new Tracker(new TrackerConfiguration("new_tracker", "appId"), emitter); + + Snowplow.setDefaultTracker(tracker); + + assertEquals(1, Snowplow.getInstancedTrackerNamespaces().size()); + assertEquals("new_tracker", Snowplow.getDefaultTracker().getNamespace()); + } + + @Test + public void createsTrackerFromConfigs() { + TrackerConfiguration trackerConfig = new TrackerConfiguration("namespace", "appId") + .base64Encoded(false) + .platform(DevicePlatform.Desktop); + NetworkConfiguration networkConfig = new NetworkConfiguration("http://collector-endpoint"); + + Tracker tracker = Snowplow.createTracker(trackerConfig, networkConfig); + Tracker retrievedTracker = Snowplow.getTracker("namespace"); + + assertFalse(Snowplow.getInstancedTrackerNamespaces().isEmpty()); + assertEquals(tracker, retrievedTracker); + assertEquals("namespace", tracker.getNamespace()); + assertEquals("appId", tracker.getAppId()); + assertEquals(DevicePlatform.Desktop, tracker.getPlatform()); + assertFalse(tracker.getBase64Encoded()); + } +} diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/SubjectTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/SubjectTest.java new file mode 100644 index 00000000..7af8f4b3 --- /dev/null +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/SubjectTest.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker; + +// Java +import java.util.HashMap; +import java.util.Map; + +// JUnit +import com.snowplowanalytics.snowplow.tracker.configuration.SubjectConfiguration; +import org.junit.Test; +import static org.junit.Assert.assertEquals; + +public class SubjectTest { + + @Test + public void testSetUserId() { + Subject subject = new Subject(); + subject.setUserId("user1"); + assertEquals("user1", subject.getSubject().get("uid")); + } + + @Test + public void testSetScreenResolution() { + Subject subject = new Subject(); + subject.setScreenResolution(100, 150); + assertEquals("100x150", subject.getSubject().get("res")); + } + + @Test + public void testSetViewPort() { + Subject subject = new Subject(); + subject.setViewPort(150, 100); + assertEquals("150x100", subject.getSubject().get("vp")); + } + + @Test + public void testSetColorDepth() { + Subject subject = new Subject(); + subject.setColorDepth(10); + assertEquals("10", subject.getSubject().get("cd")); + } + + @Test + public void testSetTimezone2() { + Subject subject = new Subject(); + subject.setTimezone("America/Toronto"); + assertEquals("America/Toronto", subject.getSubject().get("tz")); + } + + @Test + public void testSetLanguage() { + Subject subject = new Subject(); + subject.setLanguage("EN"); + assertEquals("EN", subject.getSubject().get("lang")); + } + + @Test + public void testSetIpAddress() { + Subject subject = new Subject(); + subject.setIpAddress("127.0.0.1"); + assertEquals("127.0.0.1", subject.getSubject().get("ip")); + } + + @Test + public void testSetUseragent() { + Subject subject = new Subject(); + subject.setUseragent("useragent"); + assertEquals("useragent", subject.getSubject().get("ua")); + } + + @Test + public void testSetDomainUserId() { + Subject subject = new Subject(); + subject.setDomainUserId("duid"); + assertEquals("duid", subject.getSubject().get("duid")); + } + + @Test + public void testSetNetworkUserId() { + Subject subject = new Subject(); + subject.setNetworkUserId("nuid"); + assertEquals("nuid", subject.getSubject().get("tnuid")); + } + + @Test + public void testSetDomainSessionId() { + Subject subject = new Subject(); + subject.setDomainSessionId("sessionid"); + assertEquals("sessionid", subject.getSubject().get("sid")); + } + + @Test + public void testGetSubject() { + Subject subject = new Subject(); + Map expected = new HashMap<>(); + subject.setTimezone("America/Toronto"); + subject.setUserId("user1"); + + expected.put("tz", "America/Toronto"); + expected.put("uid", "user1"); + + assertEquals(expected, subject.getSubject()); + } + + @Test + public void testBuilderMethods() { + Subject subject = new Subject(); + subject + .userId("user1") + .screenResolution(100, 150) + .viewPort(150, 100) + .colorDepth(10) + .timezone("America/Toronto") + .language("EN") + .ipAddress("127.0.0.1") + .useragent("useragent") + .domainUserId("duid") + .domainSessionId("sessionid") + .networkUserId("nuid"); + assertEquals("user1", subject.getSubject().get("uid")); + assertEquals("100x150", subject.getSubject().get("res")); + assertEquals("150x100", subject.getSubject().get("vp")); + assertEquals("10", subject.getSubject().get("cd")); + assertEquals("America/Toronto", subject.getSubject().get("tz")); + assertEquals("EN", subject.getSubject().get("lang")); + assertEquals("127.0.0.1", subject.getSubject().get("ip")); + assertEquals("useragent", subject.getSubject().get("ua")); + assertEquals("duid", subject.getSubject().get("duid")); + assertEquals("sessionid", subject.getSubject().get("sid")); + assertEquals("nuid", subject.getSubject().get("tnuid")); + } + + @Test + public void testCreateFromConfig() { + SubjectConfiguration subjectConfig = new SubjectConfiguration() + .ipAddress("xxx.000.xxx.111") + .viewPort(123, 456) + .useragent("Mac OS"); + Subject subject = new Subject(subjectConfig); + + assertEquals("xxx.000.xxx.111", subject.getSubject().get("ip")); + assertEquals("123x456", subject.getSubject().get("vp")); + assertEquals("Mac OS", subject.getSubject().get("ua")); + } +} diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerTest.java new file mode 100644 index 00000000..c2bbbd36 --- /dev/null +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerTest.java @@ -0,0 +1,631 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker; + +import java.util.*; +import static java.util.Collections.singletonList; + +import com.snowplowanalytics.snowplow.tracker.configuration.NetworkConfiguration; +import com.snowplowanalytics.snowplow.tracker.configuration.TrackerConfiguration; +import com.snowplowanalytics.snowplow.tracker.emitter.BatchEmitter; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.*; + +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; +import com.snowplowanalytics.snowplow.tracker.emitter.Emitter; +import com.snowplowanalytics.snowplow.tracker.events.*; +import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; + +public class TrackerTest { + + public static final String EXPECTED_CONTEXTS = "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-1\",\"data\":[{\"schema\":\"schema\",\"data\":{\"foo\":\"bar\"}}]}"; + + public static class MockEmitter implements Emitter { + public ArrayList eventList = new ArrayList<>(); + + @Override + public boolean add(TrackerPayload payload) { + eventList.add(payload); + return true; + } + @Override + public void setBatchSize(int batchSize) {} + @Override + public void flushBuffer() {} + @Override + public int getBatchSize() { return 0; } + @Override + public List getBuffer() { return null; } + @Override + public void close() {} + } + + MockEmitter mockEmitter; + Tracker tracker; + private List contexts; + + @Before + public void setUp() { + mockEmitter = new MockEmitter(); + TrackerConfiguration trackerConfig = new TrackerConfiguration("AF003", "cloudfront").base64Encoded(false); + tracker = new Tracker(trackerConfig, mockEmitter, new Subject()); + tracker.getSubject().setTimezone("Etc/UTC"); + contexts = singletonList(new SelfDescribingJson("schema", Collections.singletonMap("foo", "bar"))); + } + + // --- Event Tests + + @Test + public void testTrackReturnsEventIdIfSuccessful() throws InterruptedException { + // a list to allow for eCommerceTransaction + List result = tracker.track(SelfDescribing.builder() + .eventData(new SelfDescribingJson( + "iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0", + Collections.singletonMap("foo", "bar") + )) + .build()); + + Thread.sleep(500); + + boolean isValidEventId = true; + try { + UUID.fromString(result.get(0)); + } catch (Exception e) { + isValidEventId = false; + } + + assertTrue(isValidEventId); + } + + @Test + public void testTrackReturnsNullIfEventWasDropped() throws InterruptedException { + class FailingMockEmitter implements Emitter { + @Override + public boolean add(TrackerPayload payload) { return false; } + @Override + public void setBatchSize(int batchSize) {} + @Override + public void flushBuffer() {} + @Override + public int getBatchSize() { return 0; } + @Override + public List getBuffer() { return null; } + @Override + public void close() {} + } + FailingMockEmitter failingMockEmitter = new FailingMockEmitter(); + tracker = new Tracker(new TrackerConfiguration("AF003", "cloudfront"), failingMockEmitter); + + List result = tracker.track(SelfDescribing.builder() + .eventData(new SelfDescribingJson( + "iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0", + Collections.singletonMap("foo", "bar") + )) + .build()); + + Thread.sleep(500); + + assertNull(result.get(0)); + } + + @Test + public void testEcommerceEvent() throws InterruptedException { + // Given + EcommerceTransactionItem item = EcommerceTransactionItem.builder() + .itemId("order_id") + .sku("sku") + .price(1.0) + .quantity(2) + .name("name") + .category("category") + .currency("currency") + .customContext(contexts) + .trueTimestamp(456789L) + .build(); + + // When + tracker.track(EcommerceTransaction.builder() + .orderId("order_id") + .totalValue(1.0) + .affiliation("affiliation") + .taxValue(2.0) + .shipping(3.0) + .city("city") + .state("state") + .country("country") + .currency("currency") + .items(item) + .customContext(contexts) + .trueTimestamp(456789L) + .build()); + + // Then + Thread.sleep(500); + + List results = mockEmitter.eventList; + assertEquals(2, results.size()); + + Map result1 = results.get(0).getMap(); + Map expected1 = new HashMap<>(); + expected1.put("e", "tr"); + expected1.put("tr_cu", "currency"); + expected1.put("co", EXPECTED_CONTEXTS); + expected1.put("tna", "AF003"); + expected1.put("aid", "cloudfront"); + expected1.put("tr_sh", "3.0"); + expected1.put("ttm", "456789"); + expected1.put("tz", "Etc/UTC"); + expected1.put("tr_co", "country"); + expected1.put("tv", Version.TRACKER); + expected1.put("p", "srv"); + expected1.put("tr_tx", "2.0"); + expected1.put("tr_af", "affiliation"); + expected1.put("tr_id", "order_id"); + expected1.put("tr_tt", "1.0"); + expected1.put("tr_ci", "city"); + expected1.put("tr_st", "state"); + + assertTrue(result1.entrySet().containsAll(expected1.entrySet())); + + Map result2 = results.get(1).getMap(); + Map expected2 = new HashMap<>(); + expected2.put("ti_nm", "name"); + expected2.put("ti_id", "order_id"); + expected2.put("e", "ti"); + expected2.put("co", EXPECTED_CONTEXTS); + expected2.put("tna", "AF003"); + expected2.put("aid", "cloudfront"); + expected2.put("ti_cu", "currency"); + expected2.put("ttm", "456789"); + expected2.put("tz", "Etc/UTC"); + expected2.put("ti_pr", "1.0"); + expected2.put("ti_qu", "2"); + expected2.put("p", "srv"); + expected2.put("tv", Version.TRACKER); + expected2.put("ti_ca", "category"); + expected2.put("ti_sk", "sku"); + + assertTrue(result2.entrySet().containsAll(expected2.entrySet())); + } + + @Test + public void testEcommerceTransactionItemAlone() throws InterruptedException { + // Although surprising, EcommerceTransactionItems are valid events and + // can be sent separately from EcommerceTransactions. + + tracker.track(EcommerceTransactionItem.builder() + .itemId("order_id") + .sku("sku") + .price(1.0) + .quantity(2) + .name("name") + .category("category") + .currency("currency") + .customContext(contexts) + .trueTimestamp(456789L) + .build()); + + // Then + Thread.sleep(500); + + Map result = mockEmitter.eventList.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("ti_nm", "name"); + expected.put("ti_id", "order_id"); + expected.put("e", "ti"); + expected.put("co", EXPECTED_CONTEXTS); + expected.put("tna", "AF003"); + expected.put("aid", "cloudfront"); + expected.put("ti_cu", "currency"); + expected.put("ttm", "456789"); + expected.put("tz", "Etc/UTC"); + expected.put("ti_pr", "1.0"); + expected.put("ti_qu", "2"); + expected.put("p", "srv"); + expected.put("tv", Version.TRACKER); + expected.put("ti_ca", "category"); + expected.put("ti_sk", "sku"); + + assertTrue(result.entrySet().containsAll(expected.entrySet())); + } + + @Test + public void testSelfDescribingEventWithContext() throws InterruptedException { + // When + tracker.track(SelfDescribing.builder() + .eventData(new SelfDescribingJson( + "iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0", + Collections.singletonMap("foo", "bar") + )) + .customContext(contexts) + .trueTimestamp(456789L) + .build()); + + // Then + Thread.sleep(500); + + Map result = mockEmitter.eventList.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("p", "srv"); + expected.put("tv", Version.TRACKER); + expected.put("e", "ue"); + expected.put("co", EXPECTED_CONTEXTS); + expected.put("tna", "AF003"); + expected.put("tz", "Etc/UTC"); + expected.put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0\",\"data\":{\"foo\":\"bar\"}}}"); + expected.put("ttm", "456789"); + expected.put("aid", "cloudfront"); + + assertTrue(result.entrySet().containsAll(expected.entrySet())); + } + + @Test + public void testSelfDescribingEventWithoutContext() throws InterruptedException { + // When + tracker.track(SelfDescribing.builder() + .eventData(new SelfDescribingJson( + "iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0", + Collections.singletonMap("foo", "baær") + )) + .trueTimestamp(456789L) + .build()); + + // Then + Thread.sleep(500); + + Map result = mockEmitter.eventList.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("p", "srv"); + expected.put("tv", Version.TRACKER); + expected.put("e", "ue"); + expected.put("tna", "AF003"); + expected.put("tz", "Etc/UTC"); + expected.put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0\",\"data\":{\"foo\":\"baær\"}}}"); + expected.put("ttm", "456789"); + expected.put("aid", "cloudfront"); + + assertTrue(result.entrySet().containsAll(expected.entrySet())); + } + + @Test + public void testSelfDescribingEventWithoutTrueTimestamp() throws InterruptedException { + // When + tracker.track(SelfDescribing.builder() + .eventData(new SelfDescribingJson( + "iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0", + Collections.singletonMap("foo", "bar") + )) + .build()); + + // Then + Thread.sleep(500); + + Map result = mockEmitter.eventList.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("p", "srv"); + expected.put("tv", Version.TRACKER); + expected.put("e", "ue"); + expected.put("tna", "AF003"); + expected.put("tz", "Etc/UTC"); + expected.put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0\",\"data\":{\"foo\":\"bar\"}}}"); + expected.put("aid", "cloudfront"); + + assertTrue(result.entrySet().containsAll(expected.entrySet())); + } + + @Test + public void testTrackPageView() throws InterruptedException { + // When + tracker.track(PageView.builder() + .pageUrl("url") + .pageTitle("title") + .referrer("referer") + .customContext(contexts) + .trueTimestamp(456789L) + .build()); + + // Then + Thread.sleep(500); + + Map result = mockEmitter.eventList.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("ttm", "456789"); + expected.put("tz", "Etc/UTC"); + expected.put("e", "pv"); + expected.put("page", "title"); + expected.put("tv", Version.TRACKER); + expected.put("p", "srv"); + expected.put("co", EXPECTED_CONTEXTS); + expected.put("tna", "AF003"); + expected.put("aid", "cloudfront"); + expected.put("refr", "referer"); + expected.put("url", "url"); + + assertTrue(result.entrySet().containsAll(expected.entrySet())); + } + + @Test + public void testTrackTwoEvents() throws InterruptedException { + // When + tracker.track(PageView.builder() + .pageUrl("url") + .pageTitle("title") + .referrer("referer") + .trueTimestamp(123456L) + .build()); + + tracker.track(SelfDescribing.builder() + .eventData(new SelfDescribingJson( + "iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0", + Collections.singletonMap("foo", "bar") + )) + .trueTimestamp(456789L) + .build()); + + // Then + Thread.sleep(500); + + List results = mockEmitter.eventList; + assertEquals(2, results.size()); + + Map result1 = results.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("ttm", "123456"); + expected.put("tz", "Etc/UTC"); + expected.put("e", "pv"); + expected.put("page", "title"); + expected.put("tv", Version.TRACKER); + expected.put("p", "srv"); + expected.put("tna", "AF003"); + expected.put("aid", "cloudfront"); + expected.put("refr", "referer"); + expected.put("url", "url"); + + assertTrue(result1.entrySet().containsAll(expected.entrySet())); + + Map result2 = results.get(1).getMap(); + Map expected2 = new HashMap<>(); + expected2.put("ttm", "456789"); + expected2.put("p", "srv"); + expected2.put("tv", Version.TRACKER); + expected2.put("e", "ue"); + expected2.put("tna", "AF003"); + expected2.put("tz", "Etc/UTC"); + expected2.put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0\",\"data\":{\"foo\":\"bar\"}}}"); + expected2.put("aid", "cloudfront"); + + assertTrue(result2.entrySet().containsAll(expected2.entrySet())); + } + + @Test + public void testTrackScreenView() throws InterruptedException { + // When + tracker.track(ScreenView.builder() + .name("name") + .id("id") + .customContext(contexts) + .trueTimestamp(456789L) + .build()); + + // Then + Thread.sleep(500); + + Map result = mockEmitter.eventList.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("ttm", "456789"); + expected.put("tz", "Etc/UTC"); + expected.put("e", "ue"); + expected.put("tv", Version.TRACKER); + expected.put("p", "srv"); + expected.put("co", EXPECTED_CONTEXTS); + expected.put("tna", "AF003"); + expected.put("aid", "cloudfront"); + expected.put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"iglu:com.snowplowanalytics.snowplow/screen_view/jsonschema/1-0-0\",\"data\":{\"id\":\"id\",\"name\":\"name\"}}}"); + + assertTrue(result.entrySet().containsAll(expected.entrySet())); + } + + @Test + public void testTrackScreenViewWithTimestamp() throws InterruptedException { + // When + tracker.track(ScreenView.builder() + .name("name") + .id("id") + .trueTimestamp(456789L) + .build()); + + // Then + Thread.sleep(500); + + Map result = mockEmitter.eventList.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("ttm", "456789"); + expected.put("tz", "Etc/UTC"); + expected.put("e", "ue"); + expected.put("tv", Version.TRACKER); + expected.put("p", "srv"); + expected.put("tna", "AF003"); + expected.put("aid", "cloudfront"); + expected.put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"iglu:com.snowplowanalytics.snowplow/screen_view/jsonschema/1-0-0\",\"data\":{\"id\":\"id\",\"name\":\"name\"}}}"); + + assertTrue(result.entrySet().containsAll(expected.entrySet())); + } + + @Test + public void testTrackScreenViewWithDefaultContextAndTimestamp() throws InterruptedException { + // When + tracker.track(ScreenView.builder() + .name("name") + .id("id") + .customContext(contexts) + .trueTimestamp(456789L) + .build()); + + // Then + Thread.sleep(500); + + Map result = mockEmitter.eventList.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("p", "srv"); + expected.put("tv", Version.TRACKER); + expected.put("e", "ue"); + expected.put("co", EXPECTED_CONTEXTS); + expected.put("tna", "AF003"); + expected.put("tz", "Etc/UTC"); + expected.put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"iglu:com.snowplowanalytics.snowplow/screen_view/jsonschema/1-0-0\",\"data\":{\"id\":\"id\",\"name\":\"name\"}}}"); + expected.put("ttm", "456789"); + expected.put("aid", "cloudfront"); + + assertTrue(result.entrySet().containsAll(expected.entrySet())); + } + + @Test + public void testTrackTiming() throws InterruptedException { + // When + tracker.track(Timing.builder() + .category("category") + .label("label") + .variable("variable") + .timing(10) + .customContext(contexts) + .trueTimestamp(456789L) + .build()); + + // Then + Thread.sleep(500); + + Map result = mockEmitter.eventList.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("p", "srv"); + expected.put("tv", Version.TRACKER); + expected.put("e", "ue"); + expected.put("co", EXPECTED_CONTEXTS); + expected.put("tna", "AF003"); + expected.put("tz", "Etc/UTC"); + expected.put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"iglu:com.snowplowanalytics.snowplow/timing/jsonschema/1-0-0\",\"data\":{\"category\":\"category\",\"label\":\"label\",\"timing\":10,\"variable\":\"variable\"}}}"); + expected.put("ttm", "456789"); + expected.put("aid", "cloudfront"); + + assertTrue(result.entrySet().containsAll(expected.entrySet())); + } + + @Test + public void testTrackTimingWithSubject() throws InterruptedException { + // Make Subject + Subject s1 = new Subject(); + s1.setIpAddress("127.0.0.1"); + s1.setTimezone("Etc/UTC"); + + // When + tracker.track(Timing.builder() + .category("category") + .label("label") + .variable("variable") + .timing(10) + .customContext(contexts) + .trueTimestamp(456789L) + .subject(s1) + .build()); + + // Then + Thread.sleep(500); + + Map result = mockEmitter.eventList.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("p", "srv"); + expected.put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"iglu:com.snowplowanalytics.snowplow/timing/jsonschema/1-0-0\",\"data\":{\"category\":\"category\",\"label\":\"label\",\"timing\":10,\"variable\":\"variable\"}}}"); + expected.put("tv", Version.TRACKER); + expected.put("e", "ue"); + expected.put("ip", "127.0.0.1"); + expected.put("co", EXPECTED_CONTEXTS); + expected.put("tna", "AF003"); + expected.put("tz", "Etc/UTC"); + expected.put("ttm", "456789"); + expected.put("aid", "cloudfront"); + + assertTrue(result.entrySet().containsAll(expected.entrySet())); + + } + + // --- Tracker Setter & Getter Tests + + @Test + public void testCreateWithConfiguration() { + TrackerConfiguration trackerConfig = new TrackerConfiguration("namespace", "appId"); + trackerConfig.base64Encoded(false); + trackerConfig.platform(DevicePlatform.General); + + BatchEmitter emitter = new BatchEmitter(new NetworkConfiguration("http://collector")); + Tracker tracker = new Tracker(trackerConfig, emitter); + + assertEquals("namespace", tracker.getNamespace()); + assertEquals(emitter, tracker.getEmitter()); + } + + @Test + public void testGetTrackerVersion() { + Tracker tracker = new Tracker(new TrackerConfiguration("namespace", "an-app-id"), mockEmitter); + assertEquals("java-2.1.0", tracker.getTrackerVersion()); + } + + @Test + public void testSetDefaultPlatform() { + TrackerConfiguration trackerConfig = new TrackerConfiguration("AF003", "cloudfront") + .platform(DevicePlatform.Desktop); + + Tracker tracker = new Tracker(trackerConfig, mockEmitter); + assertEquals(DevicePlatform.Desktop, tracker.getPlatform()); + } + + @Test + public void testSetSubject() { + // Subject objects always have timezone set + TimeZone.setDefault(TimeZone.getTimeZone("Etc/UTC")); + + Subject s1 = new Subject(); + s1.setLanguage("EN"); + Tracker tracker = new Tracker(new TrackerConfiguration("AF003", "cloudfront"), mockEmitter, s1); + + Subject s2 = new Subject(); + s2.setColorDepth(24); + tracker.setSubject(s2); + + Map subjectPairs = new HashMap<>(); + subjectPairs.put("tz", "Etc/UTC"); + subjectPairs.put("cd", "24"); + + assertEquals(subjectPairs, tracker.getSubject().getSubject()); + } + + @Test + public void testSetBase64Encoded() { + TrackerConfiguration trackerConfig = new TrackerConfiguration("AF003", "cloudfront").base64Encoded(false); + tracker = new Tracker(trackerConfig, mockEmitter); + + assertFalse(tracker.getBase64Encoded()); + } + + @Test + public void testSetAppId() { + Tracker tracker = new Tracker(new TrackerConfiguration("AF003", "an-app-id"), mockEmitter); + assertEquals("an-app-id", tracker.getAppId()); + } + + @Test + public void testSetNamespace() { + Tracker tracker = new Tracker(new TrackerConfiguration("namespace", "an-app-id"), mockEmitter); + + assertEquals("namespace", tracker.getNamespace()); + } +} diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/UtilsTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/UtilsTest.java new file mode 100644 index 00000000..24ba8e44 --- /dev/null +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/UtilsTest.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker; + +// JUnit +import org.junit.Test; + +// Java +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.Assert.*; + +public class UtilsTest { + + @Test + public void testGetTimestamp() { + String ts = Utils.getTimestamp(); + assertNotNull(ts); + assertEquals(13, ts.length()); + } + + @Test + public void testGetEventId() { + String eid = Utils.getEventId(); + assertNotNull(eid); + assertTrue(eid.matches("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$")); + } + + @Test + public void testGetTransactionId() { + int tid = Utils.getTransactionId(); + assertTrue(tid > 100000 && tid < 999999); + } + + @Test + public void testIsUriValid() { + String goodUri1 = "http://www.acme.com"; + assertTrue(Utils.isValidUrl(goodUri1)); + String goodUri2 = "https://www.acme.com"; + assertTrue(Utils.isValidUrl(goodUri2)); + String goodUri3 = "ftp://www.acme.com"; + assertTrue(Utils.isValidUrl(goodUri3)); + + String badUri1 = "www.acme.com"; + assertFalse(Utils.isValidUrl(badUri1)); + String badUri2 = "http://"; + assertFalse(Utils.isValidUrl(badUri2)); + } + + @Test + public void testMapToJSONString() { + Map payload = new LinkedHashMap<>(); + payload.put("k1", "v1"); + assertEquals("{\"k1\":\"v1\"}", Utils.mapToJSONString(payload)); + + Map payload2 = new LinkedHashMap<>(); + payload2.put("k1", new Object()); + assertEquals("", Utils.mapToJSONString(payload2)); + + Map payload3 = new LinkedHashMap<>(); + payload3.put("k1", LocalDateTime.of(2020, 1, 1, 0, 0)); + assertEquals("{\"k1\":\"2020-01-01T00:00:00\"}", Utils.mapToJSONString(payload3)); + + Map payload4 = new LinkedHashMap<>(); + payload4.put("k1", LocalDate.of(2020, 1, 1)); + assertEquals("{\"k1\":\"2020-01-01\"}", Utils.mapToJSONString(payload4)); + } + + @Test + public void testMapToQueryString() { + Map payload = new LinkedHashMap<>(); + payload.put("k1", "v1"); + payload.put("k2", "s p a c e"); + payload.put("k3", "s+p+a+c+e"); + + assertEquals("k1=v1&k2=s%20p%20a%20c%20e&k3=s%2Bp%2Ba%2Bc%2Be", Utils.mapToQueryString(payload)); + } + + @Test + public void testObjectToUTF8() { + assertEquals("", Utils.urlEncodeUTF8(null)); + assertEquals( + "%3C%20%3E%20%23%20%25%20%7B%20%7D%20%7C%20%5C%20%5E%20%7E%20%5B%20%5D%20%60%20%3B%20%2F%20%3F%20%3A%20%40%20%3D%20%26%20%24%20%2B%20%22", + Utils.urlEncodeUTF8("< > # % { } | \\ ^ ~ [ ] ` ; / ? : @ = & $ + \"")); + } + + @Test + public void testGetTimezone() { + String tz = Utils.getTimezone(); + assertNotNull(tz); + } + + @Test + public void testBase64Encode() { + String expected = "aGVsbG93b3JsZHRlc3RiNjR3aXRodXRmOGNoYXJzw7TDqcOgw6c="; + String b64encoded = Utils.base64Encode("helloworldtestb64withutf8charsôéàç", StandardCharsets.UTF_8); + assertEquals(expected, b64encoded); + + } + + @Test + public void testGetUtf8Length() { + long expected = 20; + long utf8Length = Utils.getUTF8Length("helloworldTest123456"); + assertEquals(expected, utf8Length); + } +} diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchEmitterBuilderTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchEmitterBuilderTest.java new file mode 100644 index 00000000..dc9efb63 --- /dev/null +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchEmitterBuilderTest.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.emitter; + +import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; +import com.snowplowanalytics.snowplow.tracker.configuration.NetworkConfiguration; +import com.snowplowanalytics.snowplow.tracker.http.HttpClientAdapter; +import org.junit.Assert; +import org.junit.Test; + +public class BatchEmitterBuilderTest { + + @Test + public void setNeitherHttpClientAdapterOrCollectorUrl_shouldThrowException() { + String collectorUrl = null; + Exception exception = Assert.assertThrows(Exception.class, () -> new BatchEmitter(new NetworkConfiguration(collectorUrl))); + Assert.assertEquals("Collector url must be specified if not using a httpClientAdapter", exception.getMessage()); + } + + @Test + public void setCollectorUrlAndNoHttpClientAdapter_shouldInitialiseCorrectly() { + BatchEmitter emitter = new BatchEmitter(new NetworkConfiguration("https://mycollector.com")); + Assert.assertNotNull(emitter); + } + + @Test + public void setHttpClientAdapterAndNoCollectorUrl_shouldInitialiseCorrectly() { + HttpClientAdapter mockHttpClientAdapter = new HttpClientAdapter() { + @Override + public int post(SelfDescribingJson payload) { + return 0; + } + + @Override + public int get(TrackerPayload payload) { + return 0; + } + + @Override + public String getUrl() { + return null; + } + + @Override + public Object getHttpClient() { + return null; + } + }; + + BatchEmitter emitter = new BatchEmitter(new NetworkConfiguration(mockHttpClientAdapter)); + Assert.assertNotNull(emitter); + } +} diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchEmitterTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchEmitterTest.java new file mode 100644 index 00000000..0eebdc6f --- /dev/null +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchEmitterTest.java @@ -0,0 +1,609 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.emitter; + +import java.util.*; +import java.util.regex.Pattern; + +import com.snowplowanalytics.snowplow.tracker.configuration.EmitterConfiguration; +import com.snowplowanalytics.snowplow.tracker.configuration.NetworkConfiguration; +import com.snowplowanalytics.snowplow.tracker.constants.Parameter; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; +import com.snowplowanalytics.snowplow.tracker.events.PageView; +import com.snowplowanalytics.snowplow.tracker.http.HttpClientAdapter; + +public class BatchEmitterTest { + + private MockHttpClientAdapter mockHttpClientAdapter; + private BatchEmitter emitter; + + public static class MockHttpClientAdapter implements HttpClientAdapter { + private final int statusCode; + public boolean isGetCalled = false; + public boolean isPostCalled = false; + public int postCounter = 0; + public SelfDescribingJson capturedPayload; + + public MockHttpClientAdapter(int statusCode) { + this.statusCode = statusCode; + } + + @Override + public int post(SelfDescribingJson payload) { + isPostCalled = true; + postCounter++; + capturedPayload = payload; + return statusCode; + } + + @Override + public int get(TrackerPayload payload) { + isGetCalled = true; + return 0; + } + + @Override + public String getUrl() { return null; } + + @Override + public Object getHttpClient() { return null; } + } + + // this class fails to "send" the first 4 requests (status code 500) + // but returns a successful result (200) subsequently + static class FlakyHttpClientAdapter implements HttpClientAdapter { + int failedPostCounter = 0; + int successfulPostCounter = 0; + @Override + public int post(SelfDescribingJson payload) { + if (failedPostCounter >= 4) { + successfulPostCounter++; + return 200; + } + + failedPostCounter++; + return 500; + } + + @Override + public int get(TrackerPayload payload) { return 0; } + + @Override + public String getUrl() { return null; } + + @Override + public Object getHttpClient() { return null; } + } + + @Before + public void setUp() { + mockHttpClientAdapter = new MockHttpClientAdapter(200); + EmitterConfiguration emitterConfig = new EmitterConfiguration().batchSize(10); + emitter = new BatchEmitter(new NetworkConfiguration(mockHttpClientAdapter), emitterConfig); + } + + @Test + public void addToBuffer_withLess10Payloads_shouldNotEmptyBuffer() throws InterruptedException { + TrackerPayload payload = createPayload(); + boolean result = emitter.add(payload); + + Thread.sleep(500); + + Assert.assertTrue(result); + Assert.assertFalse(mockHttpClientAdapter.isPostCalled); + Assert.assertEquals(1, emitter.getBuffer().size()); + Assert.assertEquals(payload, emitter.getBuffer().get(0)); + } + + @Test + public void addToBuffer_withMore10Payloads_shouldEmptyBuffer() throws InterruptedException { + List payloads = createPayloads(10); + for (TrackerPayload payload : payloads) { + emitter.add(payload); + } + + Thread.sleep(500); + + Assert.assertTrue(mockHttpClientAdapter.isPostCalled); + + Assert.assertEquals(0, emitter.getBuffer().size()); + Assert.assertEquals(1, mockHttpClientAdapter.postCounter); + } + + @Test + public void addToBuffer_doesNotAddEventIfBufferFull() { + emitter = new BatchEmitter( + new NetworkConfiguration(mockHttpClientAdapter), + new EmitterConfiguration().bufferCapacity(1)); + emitter.add(createPayload()); + + TrackerPayload differentPayload = createPayload(); + boolean result = emitter.add(differentPayload); + + Assert.assertFalse(emitter.getBuffer().contains(differentPayload)); + Assert.assertFalse(result); + } + + @Test + public void flushBuffer_shouldEmptyBuffer() throws InterruptedException { + List payloads = createPayloads(2); + for (TrackerPayload payload : payloads) { + emitter.add(payload); + } + emitter.flushBuffer(); + + Thread.sleep(500); + + Assert.assertTrue(mockHttpClientAdapter.isPostCalled); + @SuppressWarnings("unchecked") + List> capturedPayload = (List>) mockHttpClientAdapter.capturedPayload.getMap().get("data"); + + assertPayload(payloads, capturedPayload); + Assert.assertEquals(0, emitter.getBuffer().size()); + } + + @Test + public void setBatchSize_WithNegativeValue_ThrowInvalidArgumentException() { + Exception exception = Assert.assertThrows(IllegalArgumentException.class, () -> emitter.setBatchSize(-1)); + Assert.assertEquals("batchSize must be greater than 0", exception.getMessage()); + } + + @Test + public void setAndGetBatchSizeWorksAsExpected() throws InterruptedException { + emitter.setBatchSize(2); + Assert.assertEquals(2, emitter.getBatchSize()); + + List payloads = createPayloads(2); + for (TrackerPayload payload : payloads) { + emitter.add(payload); + } + + Thread.sleep(500); + + Assert.assertTrue(mockHttpClientAdapter.isPostCalled); + Assert.assertEquals(0, emitter.getBuffer().size()); + } + + @Test + public void getFinalPost_shouldAddSTMParameter() throws InterruptedException { + List payloads = createPayloads(10); + for (TrackerPayload payload : payloads) { + emitter.add(payload); + } + + Thread.sleep(500); + + Assert.assertTrue(mockHttpClientAdapter.isPostCalled); + @SuppressWarnings("unchecked") + List> capturedPayload = (List>) mockHttpClientAdapter.capturedPayload.getMap().get("data"); + + for (Map payloadMap : capturedPayload) { + Assert.assertTrue(payloadMap.containsKey(Parameter.DEVICE_SENT_TIMESTAMP)); + } + } + + @Test + public void threadsHaveExpectedNames() { + // Calling flushBuffer() here to create a request thread for event sending + emitter.flushBuffer(); + + // Create a list of all live thread names + List threadList = new ArrayList<>(Thread.getAllStackTraces().keySet()); + List threadNames = new ArrayList<>(); + for (Thread thread : threadList) { + threadNames.add(thread.getName()); + } + + // Because the thread pools are named by a static ThreadFactory, + // the pool number varies if this test is run in isolation or not + boolean matchResult = false; + for (String name : threadNames) { + if (Pattern.matches("snowplow-emitter-pool-\\d+-request-thread-1", name)) { + matchResult = true; + } + } + + Assert.assertTrue(matchResult); + } + + @Test + public void close_sendsEventsAndStopsThreads() throws InterruptedException { + List payloads = createPayloads(2); + for (TrackerPayload payload : payloads) { + emitter.add(payload); + } + Thread.sleep(500); + + emitter.close(); + + Thread.sleep(500); + + // close() calls flushBuffer() to send all remaining stored events + Assert.assertTrue(mockHttpClientAdapter.isPostCalled); + Assert.assertEquals(0, emitter.getBuffer().size()); + + // these events can be added to storage but should not be sent + List morePayloads = createPayloads(20); + for (TrackerPayload payload : morePayloads) { + emitter.add(payload); + } + Assert.assertEquals(20, emitter.getBuffer().size()); + } + + @Test + public void createEmitterWithConfiguration() { + NetworkConfiguration networkConfig = new NetworkConfiguration("http://endpoint"); + EmitterConfiguration emitterConfig = new EmitterConfiguration().batchSize(5); + BatchEmitter emitter = new BatchEmitter(networkConfig, emitterConfig); + + Assert.assertEquals(5, emitter.getBatchSize()); + } + + @Test + public void eventsThatFailToSendAreReturnedToEventBuffer() throws InterruptedException { + emitter = new BatchEmitter( + new NetworkConfiguration(new FlakyHttpClientAdapter()), + new EmitterConfiguration().batchSize(10)); + + List payloads = createPayloads(2); + for (TrackerPayload payload : payloads) { + emitter.add(payload); + } + emitter.flushBuffer(); + Thread.sleep(500); + + List storedEvents = emitter.getBuffer(); + + Assert.assertEquals(2, storedEvents.size()); + Assert.assertTrue(storedEvents.contains(payloads.get(0))); + Assert.assertTrue(storedEvents.contains(payloads.get(1))); + } + + @Test + public void eventSendingFailureIncreasesBackoffTime() throws InterruptedException { + emitter = new BatchEmitter( + new NetworkConfiguration(new MockHttpClientAdapter(500)), + new EmitterConfiguration().batchSize(1)); + + emitter.add(createPayload()); + Thread.sleep(500); + + int firstDelay = emitter.getRetryDelay(); + Assert.assertNotEquals(0, firstDelay); + + emitter.add(createPayload()); + Thread.sleep(500); + + int secondDelay = emitter.getRetryDelay(); + Assert.assertTrue(secondDelay > firstDelay); + } + + @Test + public void successfulSendAfterFailureResetsBackoffTime() throws InterruptedException { + // the FlakyHttpClientAdapter returns 500 for the first 4 requests + // then subsequently returns 200 + FlakyHttpClientAdapter flakyHttpClientAdapter = new FlakyHttpClientAdapter(); + emitter = new BatchEmitter( + new NetworkConfiguration(flakyHttpClientAdapter), + new EmitterConfiguration().batchSize(1).threadCount(1)); + + List payloads = createPayloads(6); + for (TrackerPayload payload : payloads) { + emitter.add(payload); + } + + Thread.sleep(500); + + Assert.assertEquals(2, flakyHttpClientAdapter.successfulPostCounter); + Assert.assertEquals(0, emitter.getRetryDelay()); + } + + @Test + public void retryWithCustomRulesOverridingDefault() throws InterruptedException { + Map customRetry = new HashMap<>(); + customRetry.put(403, true); + + // by default 403 isn't retried + BatchEmitter emitter = new BatchEmitter( + new NetworkConfiguration(new MockHttpClientAdapter(403)), + new EmitterConfiguration().batchSize(2).customRetryForStatusCodes(customRetry)); + + List payloads = createPayloads(4); + for (TrackerPayload payload : payloads) { + emitter.add(payload); + } + + Thread.sleep(500); + Assert.assertNotEquals(0, emitter.getRetryDelay()); + Assert.assertEquals(4, emitter.getBuffer().size()); + } + + @Test + public void noRetryWithCustomRulesOverridingDefault() throws InterruptedException { + Map customRetry = new HashMap<>(); + customRetry.put(500, false); + + // by default, requests with code 500 are retried + BatchEmitter emitter = new BatchEmitter( + new NetworkConfiguration(new MockHttpClientAdapter(500)), + new EmitterConfiguration().batchSize(2).customRetryForStatusCodes(customRetry)); + + List payloads = createPayloads(4); + for (TrackerPayload payload : payloads) { + emitter.add(payload); + } + + Thread.sleep(500); + + Assert.assertEquals(0, emitter.getRetryDelay()); + Assert.assertEquals(0, emitter.getBuffer().size()); + } + + @Test + public void callsSuccessCallbackAfterSending() throws InterruptedException { + class TestCallback implements EmitterCallback { + List payloads; + final List failureTypes = new ArrayList<>(); + + @Override + public void onSuccess(List payloads) { + this.payloads = payloads; + } + + @Override + public void onFailure(FailureType failureType, boolean willRetry, List payloads) { + failureTypes.add(failureType); + } + } + + TestCallback callback = new TestCallback(); + BatchEmitter emitter = new BatchEmitter( + new NetworkConfiguration(new MockHttpClientAdapter(200)), + new EmitterConfiguration().batchSize(1).callback(callback)); + + TrackerPayload payload = createPayload(); + emitter.add(payload); + Thread.sleep(500); + + Assert.assertEquals(callback.payloads.get(0), payload); + Assert.assertTrue(callback.failureTypes.isEmpty()); + } + + @Test + public void callsFailureCallbackWhenRejectedNeedsRetry() throws InterruptedException { + class TestCallback implements EmitterCallback { + boolean willRetry; + List payloads; + final List failureTypes = new ArrayList<>(); + + @Override + public void onSuccess(List payloads) { + } + + @Override + public void onFailure(FailureType failureType, boolean willRetry, List payloads) { + failureTypes.add(failureType); + this.willRetry = willRetry; + this.payloads = payloads; + } + } + + TestCallback callback = new TestCallback(); + BatchEmitter emitter = new BatchEmitter( + new NetworkConfiguration(new MockHttpClientAdapter(500)), + new EmitterConfiguration().batchSize(1).callback(callback)); + + TrackerPayload payload = createPayload(); + emitter.add(payload); + Thread.sleep(500); + + Assert.assertEquals(FailureType.REJECTED_BY_COLLECTOR, callback.failureTypes.get(0)); + Assert.assertTrue(callback.willRetry); + Assert.assertEquals(callback.payloads.get(0), payload); + } + + @Test + public void callsFailureCallbackWhenRejectedNoRetry() throws InterruptedException { + class TestCallback implements EmitterCallback { + boolean willRetry; + List payloads; + final List failureTypes = new ArrayList<>(); + + @Override + public void onSuccess(List payloads) { + } + + @Override + public void onFailure(FailureType failureType, boolean willRetry, List payloads) { + failureTypes.add(failureType); + this.willRetry = willRetry; + this.payloads = payloads; + } + } + + TestCallback callback = new TestCallback(); + BatchEmitter emitter = new BatchEmitter( + new NetworkConfiguration(new MockHttpClientAdapter(403)), + new EmitterConfiguration().batchSize(1).callback(callback)); + + TrackerPayload payload = createPayload(); + emitter.add(payload); + Thread.sleep(500); + + Assert.assertEquals(FailureType.REJECTED_BY_COLLECTOR, callback.failureTypes.get(0)); + Assert.assertEquals(1, callback.failureTypes.size()); + Assert.assertFalse(callback.willRetry); + Assert.assertEquals(callback.payloads.get(0), payload); + } + + @Test + public void callsFailureCallbackIfStorageIsFull() throws InterruptedException { + class TestCallback implements EmitterCallback { + boolean willRetry; + List payloads; + final List failureTypes = new ArrayList<>(); + + @Override + public void onSuccess(List payloads) { + } + + @Override + public void onFailure(FailureType failureType, boolean willRetry, List payloads) { + failureTypes.add(failureType); + this.willRetry = willRetry; + this.payloads = payloads; + } + } + + TestCallback callback = new TestCallback(); + BatchEmitter emitter = new BatchEmitter( + new NetworkConfiguration(new MockHttpClientAdapter(200)), + new EmitterConfiguration().bufferCapacity(1).callback(callback)); + + emitter.add(createPayload()); + TrackerPayload payload = createPayload(); + emitter.add(payload); + Thread.sleep(500); + + Assert.assertEquals(FailureType.TRACKER_STORAGE_FULL, callback.failureTypes.get(0)); + Assert.assertEquals(1, callback.failureTypes.size()); + Assert.assertFalse(callback.willRetry); + Assert.assertEquals(callback.payloads.get(0), payload); + } + + @Test + public void callsFailureCallbackIfHttpClientAdapterFailsToSend() throws InterruptedException { + class TestCallback implements EmitterCallback { + boolean willRetry; + List payloads; + final List failureTypes = new ArrayList<>(); + + @Override + public void onSuccess(List payloads) { + } + + @Override + public void onFailure(FailureType failureType, boolean willRetry, List payloads) { + failureTypes.add(failureType); + this.willRetry = willRetry; + this.payloads = payloads; + } + } + + TestCallback callback = new TestCallback(); + BatchEmitter emitter = new BatchEmitter( + new NetworkConfiguration(new MockHttpClientAdapter(-1)), + new EmitterConfiguration().bufferCapacity(1).callback(callback)); + + TrackerPayload payload = createPayload(); + emitter.add(payload); + emitter.flushBuffer(); + Thread.sleep(500); + + Assert.assertEquals(FailureType.HTTP_CONNECTION_FAILURE, callback.failureTypes.get(0)); + Assert.assertEquals(1, callback.failureTypes.size()); + Assert.assertTrue(callback.willRetry); + Assert.assertEquals(callback.payloads.get(0), payload); + } + + @Test + public void callsFailureCallbackIfStorageIsFullWhenReturningEventsForRetry() throws InterruptedException { + class TestCallback implements EmitterCallback { + boolean willRetry; + List payloads; + final List failureTypes = new ArrayList<>(); + + @Override + public void onSuccess(List payloads) { + } + + @Override + public void onFailure(FailureType failureType, boolean willRetry, List payloads) { + failureTypes.add(failureType); + this.willRetry = willRetry; + this.payloads = payloads; + } + } + + TestCallback callback = new TestCallback(); + BatchEmitter emitter = new BatchEmitter( + new NetworkConfiguration(new MockHttpClientAdapter(500)), + new EmitterConfiguration().bufferCapacity(2).callback(callback)); + + TrackerPayload payload1 = createPayload(); + TrackerPayload payload2 = createPayload(); + TrackerPayload payload3 = createPayload(); + + emitter.add(payload1); + emitter.flushBuffer(); + Thread.sleep(10); + + emitter.add(payload2); + emitter.add(payload3); + Thread.sleep(500); + + Assert.assertEquals(FailureType.REJECTED_BY_COLLECTOR, callback.failureTypes.get(0)); + Assert.assertEquals(FailureType.TRACKER_STORAGE_FULL, callback.failureTypes.get(1)); + Assert.assertEquals(2, callback.failureTypes.size()); + Assert.assertFalse(callback.willRetry); + Assert.assertEquals(callback.payloads.get(0), payload3); + } + + private TrackerPayload createPayload() { + PageView pv = PageView.builder() + .pageUrl("https://www.snowplowanalytics.com/") + .pageTitle("Snowplow") + .referrer("https://www.google.com/") + .build(); + + return pv.getPayload(); + } + + private List createPayloads(int numPayloads) { + final List payloads = new ArrayList<>(); + for (int i = 0; i < numPayloads; i++) { + payloads.add(createPayload()); + } + return payloads; + } + + private void assertPayload(List payloads, List> capturedPayload) { + List> eventPayloads = new ArrayList<>(); + for (TrackerPayload payload : payloads) { + eventPayloads.add(payload.getMap()); + } + + //Iterate through all captured payloads + for (Map capturedMap : capturedPayload) { + boolean matchFound = false; + for (Map eventMap : eventPayloads) { + //Find the matching events + if (Objects.equals(capturedMap.get("eid"), eventMap.get("eid"))) { + matchFound = true; + + //Assert that all the entries in the event are in the captured payload + //There might be extra entries in capturedMap, such as the STM parameter + //check for these additional parameters in other tests + Assert.assertTrue(capturedMap.entrySet().containsAll(eventMap.entrySet())); + } + } + Assert.assertTrue(matchFound); //Ensure every event was found + } + } +} + diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/emitter/CollectorCookieJarTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/emitter/CollectorCookieJarTest.java new file mode 100644 index 00000000..b50a9047 --- /dev/null +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/emitter/CollectorCookieJarTest.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.emitter; + +import com.snowplowanalytics.snowplow.tracker.http.CollectorCookieJar; +import okhttp3.Cookie; +import okhttp3.HttpUrl; +import org.junit.Before; +import org.junit.Test; + +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.*; + +public class CollectorCookieJarTest { + String domain1 = "http://snowplow.test.url.com"; + String domain2 = "http://other.test.url.com"; + Cookie cookie1; + CollectorCookieJar cookieJar; + List requestCookies; + + @Before + public void setUp() { + cookie1 = new Cookie.Builder() + .name("sp") + .value("xxx") + .domain("snowplow.test.url.com") + .build(); + cookieJar = new CollectorCookieJar(); + } + + @Test + public void testNoCookiesAtStartup() { + List cookies = cookieJar.loadForRequest(HttpUrl.parse(domain1)); + assertTrue(cookies.isEmpty()); + } + + @Test + public void testReturnsCookiesAfterSetInResponse() { + requestCookies = Collections.singletonList(cookie1); + cookieJar.saveFromResponse( + HttpUrl.parse(domain1), + requestCookies + ); + + List cookies2 = cookieJar.loadForRequest(HttpUrl.parse(domain1)); + assertFalse(cookies2.isEmpty()); + assertEquals(cookies2.get(0).name(), "sp"); + + cookieJar.clear(); + } + + @Test + public void testDoesntReturnCookiesForDifferentDomain() { + requestCookies = Collections.singletonList(cookie1); + cookieJar.saveFromResponse( + HttpUrl.parse(domain1), + requestCookies + ); + + List cookies2 = cookieJar.loadForRequest(HttpUrl.parse(domain2)); + assertTrue(cookies2.isEmpty()); + + cookieJar.clear(); + } + + @Test + public void testMaintainsCookiesAcrossJarInstances() { + requestCookies = Collections.singletonList(cookie1); + cookieJar.saveFromResponse( + HttpUrl.parse(domain1), + requestCookies + ); + + CollectorCookieJar cookieJar2 = new CollectorCookieJar(); + List cookies2 = cookieJar2.loadForRequest(HttpUrl.parse(domain1)); + assertFalse(cookies2.isEmpty()); + + cookieJar.clear(); + } + + @Test + public void testClearsCookies() { + requestCookies = Collections.singletonList(cookie1); + cookieJar.saveFromResponse( + HttpUrl.parse(domain1), + requestCookies + ); + + List cookies = cookieJar.loadForRequest(HttpUrl.parse(domain1)); + assertFalse(cookies.isEmpty()); + + cookieJar.clear(); + List cookies2 = cookieJar.loadForRequest(HttpUrl.parse(domain1)); + assertTrue(cookies2.isEmpty()); + } + + @Test + public void testRemovesExpiredCookies() { + Cookie cookie2 = new Cookie.Builder() + .name("sp") + .value("xxx") + .domain("snowplow.test.url.com") + .expiresAt(1654869235L) + .build(); + + requestCookies = Collections.singletonList(cookie2); + cookieJar.saveFromResponse( + HttpUrl.parse(domain1), + requestCookies + ); + + List cookies = cookieJar.loadForRequest(HttpUrl.parse(domain1)); + assertTrue(cookies.isEmpty()); + } + +} diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/emitter/InMemoryEventStoreTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/emitter/InMemoryEventStoreTest.java new file mode 100644 index 00000000..e9552746 --- /dev/null +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/emitter/InMemoryEventStoreTest.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.emitter; + +import com.snowplowanalytics.snowplow.tracker.events.PageView; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +public class InMemoryEventStoreTest { + + private TrackerPayload trackerPayload; + private InMemoryEventStore eventStore; + + @Before + public void setUp() { + trackerPayload = createTrackerPayload(); + eventStore = new InMemoryEventStore(); + } + + @Test + public void correctlyAddAnEventToStore() { + boolean result = eventStore.addEvent(trackerPayload); + + Assert.assertTrue(result); + } + + @Test + public void getSize_returnsCorrectNumberOfStoredEvents() { + eventStore.addEvent(trackerPayload); + eventStore.addEvent(trackerPayload); + + Assert.assertEquals(2, eventStore.size()); + } + + @Test + public void getEventsFromStorage() { + eventStore.addEvent(trackerPayload); + eventStore.addEvent(trackerPayload); + eventStore.addEvent(trackerPayload); + eventStore.addEvent(trackerPayload); + + Assert.assertEquals(2, eventStore.getEventsBatch(2).getPayloads().size()); + Assert.assertEquals(2, eventStore.size()); + } + + @Test + public void doNotGetEventsIfFewerPresentThanAskedFor() throws NullPointerException { + eventStore.addEvent(trackerPayload); + eventStore.addEvent(trackerPayload); + + BatchPayload events = eventStore.getEventsBatch(3); + + Assert.assertNull(events); + } + + @Test + public void putEventsBackInBufferIfFailedToSend() { + eventStore.addEvent(trackerPayload); + eventStore.addEvent(trackerPayload); + eventStore.getEventsBatch(2); + + Assert.assertEquals(0, eventStore.size()); + + eventStore.cleanupAfterSendingAttempt(true, 1L); + + Assert.assertEquals(2, eventStore.size()); + } + + @Test + public void doNotPutEventsBackInBufferIfSent() { + eventStore.addEvent(trackerPayload); + eventStore.addEvent(trackerPayload); + eventStore.getEventsBatch(2); + + Assert.assertEquals(0, eventStore.size()); + + eventStore.cleanupAfterSendingAttempt(false, 1L); + + Assert.assertEquals(0, eventStore.size()); + } + + @Test + public void dropNewerEventsOnFailureWhenBufferFull() { + eventStore = new InMemoryEventStore(3); + + TrackerPayload differentPayload = createTrackerPayload(); + + eventStore.addEvent(differentPayload); + eventStore.getEventsBatch(1); + + eventStore.addEvent(trackerPayload); + eventStore.addEvent(trackerPayload); + eventStore.addEvent(trackerPayload); + + eventStore.cleanupAfterSendingAttempt(true, 1L); + Assert.assertEquals(3, eventStore.size()); + Assert.assertTrue(eventStore.getAllEvents().contains(differentPayload)); + } + + private TrackerPayload createTrackerPayload() { + PageView pv = PageView.builder() + .pageUrl("https://www.snowplowanalytics.com/") + .pageTitle("Snowplow") + .referrer("https://www.google.com/") + .build(); + + return pv.getPayload(); + } +} diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/events/SelfDescribingTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/events/SelfDescribingTest.java new file mode 100644 index 00000000..01e21e61 --- /dev/null +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/events/SelfDescribingTest.java @@ -0,0 +1,37 @@ +package com.snowplowanalytics.snowplow.tracker.events; + +import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; + +// JUnit +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +public class SelfDescribingTest { + + @Test + public void testEqualityOfTwoInstances() { + SelfDescribing.Builder builder = SelfDescribing.builder() + .eventData(new SelfDescribingJson("schema-name")); + + SelfDescribing a = new SelfDescribing( builder ); + SelfDescribing b = new SelfDescribing( builder ); + + assertEquals(a, b); + } + + @Test + public void testNegativeEqualityOfTwoInstances() { + SelfDescribing.Builder builderOne = SelfDescribing.builder() + .eventData(new SelfDescribingJson("schema-name-one")); + + SelfDescribing.Builder builderTwo = SelfDescribing.builder() + .eventData(new SelfDescribingJson("schema-name-two")); + + SelfDescribing a = new SelfDescribing( builderOne ); + SelfDescribing b = new SelfDescribing( builderTwo ); + + assertNotEquals(a, b); + } + +} \ No newline at end of file diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/http/HttpClientAdapterTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/http/HttpClientAdapterTest.java new file mode 100644 index 00000000..031e67ce --- /dev/null +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/http/HttpClientAdapterTest.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.http; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +import org.apache.hc.client5.http.impl.classic.HttpClients; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import static org.junit.Assert.assertEquals; + +import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +@RunWith(Parameterized.class) +public class HttpClientAdapterTest { + + private final MockWebServer mockWebServer; + private HttpClientAdapter adapter; + + interface HttpClientAdapterProvider { + HttpClientAdapter provide(String uri); + } + + @Parameterized.Parameters + public static Collection data() { + return Arrays.asList(new Object[][]{ + {new HttpClientAdapterProvider() { + @Override + public HttpClientAdapter provide(String url) { + return new ApacheHttpClientAdapter(url, HttpClients.createDefault()); + } + }}, + {new HttpClientAdapterProvider() { + @Override + public HttpClientAdapter provide(String url) { + OkHttpClient httpClient = new OkHttpClient.Builder() + .connectTimeout(1, TimeUnit.SECONDS) + .readTimeout(1, TimeUnit.SECONDS) + .writeTimeout(1, TimeUnit.SECONDS) + .build(); + return new OkHttpClientAdapter(url, httpClient); + } + } + } + }); + } + + public HttpClientAdapterTest(HttpClientAdapterProvider httpClientAdapterProvider) throws IOException { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + adapter = httpClientAdapterProvider.provide(mockWebServer.url("/").toString()); + } + + @Test + public void get_withSuccessfulStatusCode_isOk() throws Exception { + // Given + mockWebServer.enqueue(new MockResponse().setResponseCode(200)); + + // When + TrackerPayload data = new TrackerPayload(); + data.add("foo", "bar"); + data.add("space", "b a r"); + adapter.get(data); + + String eventId = data.getEventId(); + String dtm = Long.toString(data.getDeviceCreatedTimestamp()); + + // Then + assertEquals(1, mockWebServer.getRequestCount()); + RecordedRequest recordedRequest = mockWebServer.takeRequest(); + + String expectedString = "/i?eid=" + eventId + "&dtm=" + dtm + "&foo=bar&space=b%20a%20r"; + assertEquals(expectedString, recordedRequest.getPath()); + assertEquals("GET", recordedRequest.getMethod()); + } + + @Test + public void post_withSuccessfulStatusCode_isOk() throws InterruptedException { + // Given + mockWebServer.enqueue(new MockResponse().setResponseCode(200)); + + // When + int responseCode = adapter.post(new SelfDescribingJson("schema", Collections.singletonMap("foo", "bar"))); + + // Then + assertEquals(200, responseCode); + assertEquals(1, mockWebServer.getRequestCount()); + RecordedRequest recordedRequest = mockWebServer.takeRequest(); + assertEquals("/com.snowplowanalytics.snowplow/tp2", recordedRequest.getPath()); + assertEquals("{\"schema\":\"schema\",\"data\":{\"foo\":\"bar\"}}", recordedRequest.getBody().readUtf8()); + assertEquals("POST", recordedRequest.getMethod()); + assertEquals("application/json; charset=utf-8", recordedRequest.getHeader("Content-Type")); + } + + @Test + public void post_withUnsuccessfulStatusCode_isOk() throws InterruptedException { + // Given + mockWebServer.enqueue(new MockResponse().setResponseCode(404)); + + // When + int responseCode = adapter.post(new SelfDescribingJson("schema", Collections.singletonMap("foo", "bar"))); + + // Then + assertEquals(404, responseCode); + assertEquals(1, mockWebServer.getRequestCount()); + RecordedRequest recordedRequest = mockWebServer.takeRequest(); + assertEquals("/com.snowplowanalytics.snowplow/tp2", recordedRequest.getPath()); + assertEquals("{\"schema\":\"schema\",\"data\":{\"foo\":\"bar\"}}", recordedRequest.getBody().readUtf8()); + assertEquals("POST", recordedRequest.getMethod()); + assertEquals("application/json; charset=utf-8", recordedRequest.getHeader("Content-Type")); + } + + @Test + public void testPostWithNullArgument() { + Assert.assertThrows(NullPointerException.class, () -> adapter.post(null)); + } + + @Test + public void testGetWithNullArgument() { + Assert.assertThrows(NullPointerException.class, () -> adapter.get(null)); + } + + @Test + public void testRequestWithCookies() throws IOException, InterruptedException { + adapter = new OkHttpClientWithCookieJarAdapter(mockWebServer.url("/").toString()); + + mockWebServer.enqueue(new MockResponse().addHeader("Set-Cookie", "sp=test")); + + SelfDescribingJson payload = new SelfDescribingJson("schema", Collections.singletonMap("foo", "bar")); + + adapter.post(payload); + adapter.post(payload); + + assertEquals(2, mockWebServer.getRequestCount()); + mockWebServer.takeRequest(); + RecordedRequest recordedRequest2 = mockWebServer.takeRequest(); + + assertEquals("sp=test", recordedRequest2.getHeader("Cookie")); + + mockWebServer.shutdown(); + } +} diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/payload/SelfDescribingJsonTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/payload/SelfDescribingJsonTest.java new file mode 100644 index 00000000..68f144e1 --- /dev/null +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/payload/SelfDescribingJsonTest.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.payload; + +// Java +import java.util.HashMap; +import java.util.Map; + +// JUnit +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotEquals; + + + +public class SelfDescribingJsonTest { + + @Test + public void testMakeSdjWithoutData() { + SelfDescribingJson sdj = new SelfDescribingJson("schema_string"); + String expected = "{\"schema\":\"schema_string\",\"data\":{}}"; + String sdjString = sdj.toString(); + assertNotNull(sdj); + assertEquals(expected, sdjString); + } + + @Test + public void testMakeSdjWithObject() { + Map data = new HashMap<>(); + data.put("key", "value"); + SelfDescribingJson sdj = new SelfDescribingJson("schema_string", data); + String expected = "{\"schema\":\"schema_string\",\"data\":{\"key\":\"value\"}}"; + String sdjString = sdj.toString(); + assertNotNull(sdj); + assertEquals(expected, sdjString); + } + + @Test + public void testMakeSdjWithTrackerPayload() { + TrackerPayload data = new TrackerPayload(); + data.add("value", "key"); + String eventId = data.getEventId(); + String dtm = Long.toString(data.getDeviceCreatedTimestamp()); + + SelfDescribingJson sdj = new SelfDescribingJson("schema_string", data); + + String expected = "{\"schema\":\"schema_string\",\"data\":{\"eid\":\"" + eventId + "\",\"dtm\":\"" + dtm + "\",\"value\":\"key\"}}"; + String sdjString = sdj.toString(); + assertNotNull(sdj); + assertEquals(expected, sdjString); + } + + @Test + public void testMakeSdjWithSdj() { + SelfDescribingJson data = new SelfDescribingJson("nested_schema_string"); + SelfDescribingJson sdj = new SelfDescribingJson("schema_string", data); + String expected = "{\"schema\":\"schema_string\",\"data\":{\"schema\":\"nested_schema_string\",\"data\":{}}}"; + String sdjString = sdj.toString(); + assertNotNull(sdj); + assertEquals(expected, sdjString); + } + + @Test + public void testEqualityOfTwoInstances_withSchemaNameOnly() { + SelfDescribingJson a = new SelfDescribingJson("schema"); + SelfDescribingJson b = new SelfDescribingJson("schema"); + assertEquals(a, b); + } + + @Test + public void testEqualityOfTwoInstances_withTrackerPayload() { + TrackerPayload nestedData = new TrackerPayload(); + nestedData.add("key", "value"); + SelfDescribingJson a = new SelfDescribingJson("schema", nestedData); + SelfDescribingJson b = new SelfDescribingJson("schema", nestedData); + assertEquals(a, b); + } + + @Test + public void testEqualityOfTwoInstances_withNestedEvent() { + TrackerPayload nestedData = new TrackerPayload(); + nestedData.add("key", "value"); + SelfDescribingJson nestedEvent = new SelfDescribingJson("nested_event", nestedData); + SelfDescribingJson a = new SelfDescribingJson("schema", nestedEvent); + SelfDescribingJson b = new SelfDescribingJson("schema", nestedEvent); + assertEquals(a, b); + } + + @Test + public void testNegativeEqualityOfTwoInstances_withSchemaNameOnly() { + SelfDescribingJson a = new SelfDescribingJson("schema-one"); + SelfDescribingJson b = new SelfDescribingJson("schema-two"); + assertNotEquals(a, b); + } + + @Test + public void testNegativeEqualityOfTwoInstances_withTrackerPayload() { + TrackerPayload nestedDataOne = new TrackerPayload(); + nestedDataOne.add("key", "value-one"); + TrackerPayload nestedDataTwo = new TrackerPayload(); + nestedDataTwo.add("key", "value-two"); + SelfDescribingJson a = new SelfDescribingJson("schema", nestedDataOne); + SelfDescribingJson b = new SelfDescribingJson("schema", nestedDataTwo); + assertNotEquals(a, b); + } + + @Test + public void testNegativeEqualityOfTwoInstances_withNestedEvent() { + TrackerPayload nestedDataOne = new TrackerPayload(); + nestedDataOne.add("key", "value-one"); + SelfDescribingJson nestedEventOne = new SelfDescribingJson("nested_event", nestedDataOne); + + TrackerPayload nestedDataTwo = new TrackerPayload(); + nestedDataTwo.add("key", "value-two"); + SelfDescribingJson nestedEventTwo = new SelfDescribingJson("nested_event", nestedDataTwo); + + + SelfDescribingJson a = new SelfDescribingJson("schema", nestedEventOne); + SelfDescribingJson b = new SelfDescribingJson("schema", nestedEventTwo); + assertNotEquals(a, b); + } +} diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/payload/TrackerPayloadTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/payload/TrackerPayloadTest.java new file mode 100644 index 00000000..0034a648 --- /dev/null +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/payload/TrackerPayloadTest.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.payload; + +// Java +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +// JUnit +import org.junit.Test; + +import static org.junit.Assert.*; + +public class TrackerPayloadTest { + + @Test + public void testGetEventId() { + TrackerPayload payload = new TrackerPayload(); + + boolean isValidEventId = true; + try { + UUID.fromString(payload.getEventId()); + } catch (Exception e) { + isValidEventId = false; + } + + assertTrue(isValidEventId); + assertTrue(payload.getMap().containsKey("eid")); + assertEquals(payload.getEventId(), payload.getMap().get("eid")); + } + + @Test + public void testGetDeviceCreatedTimestamp() { + long currentTime = System.currentTimeMillis(); + TrackerPayload payload = new TrackerPayload(); + long timeDifference = payload.getDeviceCreatedTimestamp() - currentTime; + assertTrue(timeDifference < 1000); + + assertTrue(payload.getMap().containsKey("dtm")); + assertEquals(Long.toString(payload.getDeviceCreatedTimestamp()), payload.getMap().get("dtm")); + } + + @Test + public void testAddKeyValue() { + TrackerPayload payload = new TrackerPayload(); + payload.add("key", "value"); + assertNotNull(payload); + assertTrue(payload.getMap().containsKey("key")); + assertEquals("value", payload.getMap().get("key")); + } + + @Test + public void testAddKeyWithNullValue() { + TrackerPayload payload = new TrackerPayload(); + payload.add("key", null); + assertNotNull(payload); + assertFalse(payload.getMap().containsKey("key")); + } + + @Test + public void testAddKeyWithEmptyValue() { + TrackerPayload payload = new TrackerPayload(); + payload.add("key", ""); + assertNotNull(payload); + assertFalse(payload.getMap().containsKey("key")); + } + + @Test + public void testAddMap() { + Map data = new HashMap<>(); + data.put("key", "value"); + TrackerPayload payload = new TrackerPayload(); + payload.addMap(data); + assertNotNull(payload); + assertTrue(payload.getMap().containsKey("key")); + assertEquals("value", payload.getMap().get("key")); + } + + @Test + public void testAddMapWithNullValue() { + Map data = new HashMap<>(); + data.put("key", null); + TrackerPayload payload = new TrackerPayload(); + payload.addMap(data); + assertNotNull(payload); + assertFalse(payload.getMap().containsKey("key")); + } + + @Test + public void testAddMapWithEmptyValue() { + Map data = new HashMap<>(); + data.put("key", ""); + TrackerPayload payload = new TrackerPayload(); + payload.addMap(data); + assertNotNull(payload); + assertFalse(payload.getMap().containsKey("key")); + } + + @Test + public void testAddMapEncoded() { + Map data = new HashMap<>(); + data.put("key", "value"); + TrackerPayload payload = new TrackerPayload(); + payload.addMap(data, true, "encoded", "non_encoded"); + assertNotNull(payload); + assertTrue(payload.getMap().containsKey("encoded")); + assertEquals("eyJrZXkiOiJ2YWx1ZSJ9", payload.getMap().get("encoded")); + } + + @Test + public void testAddMapNonEncoded() { + Map data = new HashMap<>(); + data.put("key", "value"); + TrackerPayload payload = new TrackerPayload(); + payload.addMap(data, false, "encoded", "non_encoded"); + assertNotNull(payload); + assertTrue(payload.getMap().containsKey("non_encoded")); + assertEquals("{\"key\":\"value\"}", payload.getMap().get("non_encoded")); + } +}