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 55b41818..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,8 +55,11 @@ com_crashlytics_export_strings.xml local.properties # Ignoring Version.java since its auto-generated -#Version.java +Version.java -# Vagrant -.vagrant +#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 be5602ba..00000000 --- a/.travis.yml +++ /dev/null @@ -1,7 +0,0 @@ -language: java - -jdk: - - openjdk6 - - openjdk7 - - oraclejdk7 - - oraclejdk8 diff --git a/CHANGELOG b/CHANGELOG index 2e64686e..c750412a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,192 @@ +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) 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 a01e9050..839e8fd3 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,60 @@ # Java Analytics for Snowplow -[ ![Build Status] [travis-image] ] [travis] [ ![Release] [release-image] ] [releases] [ ![License] [license-image] ] [license] +[![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]**. See also: **[Snowplow Android Tracker] [snowplow-android-tracker]**. +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+. -## Quickstart +## Find out more + +| 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. -Assuming git, **[Vagrant] [vagrant-install]** and **[VirtualBox] [virtualbox-install]** installed: +To run the tests using Docker, run: ```bash - host$ git clone https://github.com/snowplow/snowplow-java-tracker.git - host$ cd snowplow-java-tracker - host$ vagrant up && vagrant ssh -guest$ cd /vagrant -guest$ gradle test +$ docker build . -t snowplow-java-tracker ``` -## Find out more +To run the tests using your installed JDK, run: + +```bash +$ ./gradlew build +``` -| 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]** | +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-2015 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 @@ -40,29 +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. -[travis]: https://travis-ci.org/snowplow/snowplow-java-tracker -[travis-image]: https://travis-ci.org/snowplow/snowplow-java-tracker.svg?branch=master +[github]: https://github.com/snowplow/snowplow-java-tracker/actions +[github-image]: https://github.com/snowplow/snowplow-java-tracker/workflows/Build/badge.svg -[release-image]: http://img.shields.io/badge/release-0.7.0-blue.svg?style=flat +[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]: http://img.shields.io/badge/license-Apache--2-blue.svg?style=flat -[license]: http://www.apache.org/licenses/LICENSE-2.0 +[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 +[forums]: https://discourse.snowplowanalytics.com/ [snowplow-android-tracker]: https://github.com/snowplow/snowplow-android-tracker/ - -[vagrant-install]: http://docs.vagrantup.com/v2/installation/index.html -[virtualbox-install]: https://www.virtualbox.org/wiki/Downloads +[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 +[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 + +[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/Vagrantfile b/Vagrantfile deleted file mode 100644 index 7cf18f20..00000000 --- a/Vagrantfile +++ /dev/null @@ -1,24 +0,0 @@ -Vagrant.configure("2") do |config| - - config.vm.box = "ubuntu/trusty64" - config.vm.hostname = "snowplow-java-tracker" - config.ssh.forward_agent = true - - config.vm.provider :virtualbox do |vb| - vb.name = Dir.pwd().split("/")[-1] + "-" + Time.now.to_f.to_i.to_s - vb.customize ["modifyvm", :id, "--natdnshostresolver1", "on"] - vb.customize [ "guestproperty", "set", :id, "--timesync-threshold", 10000 ] - # Need a bit of memory for Java - vb.memory = 2560 - end - - config.vm.provision :shell do |sh| - sh.path = "vagrant/up.bash" - end - - # Requires Vagrant 1.7.0+ - config.push.define "binary", strategy: "local-exec" do |push| - push.script = "vagrant/push.bash" - end - -end diff --git a/build.gradle b/build.gradle index 81539ae4..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,81 +11,91 @@ * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ -apply plugin: 'java' -apply plugin: 'maven-publish' -apply plugin: 'idea' +import java.time.Duration +plugins { + id 'java-library' + id 'maven-publish' + id 'io.github.gradle-nexus.publish-plugin' version '1.1.0' + id 'signing' + id 'idea' +} + +wrapper.gradleVersion = '6.5.0' group = 'com.snowplowanalytics' -version = '0.7.0' -sourceCompatibility = '1.6' -targetCompatibility = '1.6' +archivesBaseName = 'snowplow-java-tracker' +version = '2.1.0' +sourceCompatibility = '1.8' +targetCompatibility = '1.8' + +def javaVersion = JavaVersion.VERSION_1_8 + repositories { - // Use 'maven central' for resolving our dependencies mavenCentral() - // Use 'jcenter' for resolving testing dependencies - jcenter() } -dependencies { +configure([compileJava, compileTestJava]) { + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + options.encoding = 'UTF-8' +} +java { + registerFeature('okhttpSupport') { + usingSourceSet(sourceSets.main) + } + registerFeature('apachehttpSupport') { + usingSourceSet(sourceSets.main) + } +} + +test { + useJUnitPlatform { + includeEngines 'junit-vintage' + } +} + +dependencies { // Apache Commons - compile 'commons-codec:commons-codec:1.2' - compile 'commons-net:commons-net:3.3' + api 'commons-net:commons-net:3.10.0' + api 'commons-codec:commons-codec:1.16.0' // Apache HTTP - compile 'org.apache.httpcomponents:httpclient:4.3.3' - compile 'org.apache.httpcomponents:httpasyncclient:4.0.1' + apachehttpSupportApi 'org.apache.httpcomponents.client5:httpclient5:5.3' + + // Square OK HTTP + okhttpSupportApi 'com.squareup.okhttp3:okhttp:4.12.0' // SLF4J logging API - compile 'org.slf4j:slf4j-simple:1.7.7' + api 'org.slf4j:slf4j-api:2.0.11' + testImplementation 'org.slf4j:slf4j-simple:2.0.11' // Jackson JSON processor - compile 'com.fasterxml.jackson.core:jackson-databind:2.4.1.1' - - // Preconditions - compile 'com.google.guava:guava:17.0' + api 'com.fasterxml.jackson.core:jackson-databind:2.16.1' + api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.1' // Testing libraries - testCompile 'junit:junit:4.11' - testCompile 'com.github.tomakehurst:wiremock:1.53' - testCompile 'org.skyscreamer:jsonassert:1.2.3' -} + 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 } -// Publishing -publishing { - publications { - mavenJava(MavenPublication) { - artifactId 'snowplow-java-tracker' - 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") 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. @@ -96,10 +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"; @@ -110,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/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/RequestCallback.java b/examples/benchmarking/build.gradle similarity index 59% rename from src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/RequestCallback.java rename to examples/benchmarking/build.gradle index e73c6689..279b1a8a 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/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.emitter; +import org.gradle.api.tasks.JavaExec -import com.snowplowanalytics.snowplow.tracker.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/examples/benchmarking/gradle/wrapper/gradle-wrapper.properties b/examples/benchmarking/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..69a97150 --- /dev/null +++ b/examples/benchmarking/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +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 diff --git a/examples/benchmarking/gradlew b/examples/benchmarking/gradlew new file mode 100755 index 00000000..744e882e --- /dev/null +++ b/examples/benchmarking/gradlew @@ -0,0 +1,185 @@ +#!/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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# 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 () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# 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" = "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 + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# 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="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + 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" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +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" "$@" 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/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..a9f778a7 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,104 @@ +@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 init + +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 init + +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 + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +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% + +: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/src/main/java/com/snowplowanalytics/snowplow/tracker/DevicePlatform.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/DevicePlatform.java index ca62565d..f2a2460a 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/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. @@ -13,6 +13,9 @@ 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 848fe174..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,52 +10,317 @@ * "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.Calendar; +// Java import java.util.HashMap; import java.util.Map; -import java.util.TimeZone; +// 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; + 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() { - standardPairs = new HashMap(); + this(new SubjectConfiguration()); + } - // Default Timezone - TimeZone tz = Calendar.getInstance().getTimeZone(); - this.setTimezone(tz.getID()); + /** + * 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) { - this.standardPairs.put(Parameter.UID, 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) { - String res = Integer.toString(width) + "x" + Integer.toString(height); - this.standardPairs.put(Parameter.RESOLUTION, res); + 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) { - String res = Integer.toString(width) + "x" + Integer.toString(height); - this.standardPairs.put(Parameter.VIEWPORT, res); + 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) { - this.standardPairs.put(Parameter.COLOR_DEPTH, Integer.toString(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) { - this.standardPairs.put(Parameter.TIMEZONE, 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) { - this.standardPairs.put(Parameter.LANGUAGE, 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 299d4605..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,535 +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.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.payload.Payload; -import com.snowplowanalytics.snowplow.tracker.payload.SchemaPayload; +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 com.google.common.base.Preconditions; - -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; +import java.util.*; +/** + * Allows tracking of Events. + */ public class Tracker { - private boolean base64Encoded = true; private Emitter emitter; - private DevicePlatform platform; - private String appId; - private String namespace; - private String trackerVersion; private Subject subject; + private final TrackerParameters parameters; /** - * @param emitter Emitter to which events will be sent - * @param namespace Identifier for the Tracker instance - * @param appId Application ID + * Creates a new Snowplow Tracker. + * + * @param trackerConfig a TrackerConfiguration object + * @param emitter an Emitter + * @param subject a Subject + * */ - public Tracker(Emitter emitter, String namespace, String appId) { - this(emitter, null, namespace, appId, true); - } + 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; - /** - * @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 + * Creates a new Snowplow Tracker. + * + * @param trackerConfig a TrackerConfiguration object + * @param emitter an Emitter + * */ - public Tracker(Emitter emitter, String namespace, String appId, boolean base64Encoded) { - this(emitter, null, namespace, appId, base64Encoded); + public Tracker(TrackerConfiguration trackerConfig, Emitter emitter) { + this(trackerConfig, emitter, null); } + // --- Setters + /** - * @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 + * Change the Emitter used to send events. + * + * @param emitter a new emitter */ - public Tracker(Emitter emitter, Subject subject, String namespace, String appId, - boolean base64Encoded) { + public void setEmitter(Emitter emitter) { this.emitter = emitter; - this.appId = appId; - this.base64Encoded = base64Encoded; - this.namespace = namespace; - this.subject = subject; - this.trackerVersion = Version.TRACKER; - this.platform = DevicePlatform.Desktop; } /** - * @param payload Payload builder - * @param context Custom context for the event - * @param timestamp Optional user-provided timestamp for the event - * @return A completed Payload + * Sets a new Subject object which will get attached to + * each event payload. + * + * @param subject the new Subject */ - protected Payload completePayload(TrackerPayload payload, List context, - long timestamp) { - payload.add(Parameter.PLATFORM, this.platform.toString()); - 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(Constants.SCHEMA_CONTEXTS); - - // 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; - } - - public void setPlatform(DevicePlatform platform) { - this.platform = platform; - } - - public DevicePlatform getPlatform() { - return this.platform; - } - - protected void setTrackerVersion(String version) { - this.trackerVersion = version; - } - - private void addTrackerPayload(Payload payload) { - this.emitter.addToBuffer(payload); - } - public void setSubject(Subject subject) { this.subject = subject; } - public Subject getSubject() { - return this.subject; - } + // --- Getters /** - * @param pageUrl URL of the viewed page - * @param pageTitle Title of the viewed page - * @param referrer Referrer of the page + * @return the emitter associated with the tracker */ - public void trackPageView(String pageUrl, String pageTitle, String referrer) { - trackPageView(pageUrl, pageTitle, referrer, null, 0); + public Emitter getEmitter() { + return this.emitter; } /** - * @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 + * @return the Tracker-associated Subject */ - public void trackPageView(String pageUrl, String pageTitle, String referrer, - List context) { - trackPageView(pageUrl,pageTitle, referrer, context, 0); + public Subject getSubject() { + return this.subject; } /** - * @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 + * The Java tracker release version, e.g. 0.12.0. + * + * @return the tracker version */ - public void trackPageView(String pageUrl, String pageTitle, String referrer, - long timestamp) { - trackPageView(pageUrl, pageTitle, referrer, null, timestamp); + public String getTrackerVersion() { + return this.parameters.getTrackerVersion(); } /** - * @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 + * @return the trackers namespace */ - 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"); - - TrackerPayload 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); + public String getNamespace() { + return this.parameters.getNamespace(); } /** - * @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 + * @return the tracker Application ID */ - public void trackStructuredEvent(String category, String action, String label, String property, - int value) { - trackStructuredEvent(category, action, label, property, value, null, 0); + public String getAppId() { + return this.parameters.getAppId(); } /** - * @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 + * @return the base64 setting of the tracker */ - public void trackStructuredEvent(String category, String action, String label, String property, - int value, List context) { - trackStructuredEvent(category, action, label, property, value, context, 0); + public boolean getBase64Encoded() { + return this.parameters.getBase64Encoded(); } /** - * @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 + * @return the Tracker platform, e.g. "srv" */ - 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"); - - TrackerPayload 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); + public DevicePlatform getPlatform() { + return this.parameters.getPlatform(); } /** - * - * @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 + * @return the wrapper containing the Tracker parameters */ - public void trackUnstructuredEvent(SchemaPayload eventData) { - trackUnstructuredEvent(eventData, null, 0); + public TrackerParameters getParameters() { + return parameters; } - /** - * - * @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(SchemaPayload eventData, List context) { - trackUnstructuredEvent(eventData, context, 0); - } + // --- Event Tracking Functions /** + * Handles tracking the different types of events. * - * @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(SchemaPayload eventData, long timestamp) { - trackUnstructuredEvent(eventData, null, timestamp); - } - - /** + * 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 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(SchemaPayload eventData, List context, - long timestamp) { - TrackerPayload payload = new TrackerPayload(); - SchemaPayload envelope = new SchemaPayload(); - - envelope.setSchema(Constants.SCHEMA_UNSTRUCT_EVENT); - envelope.setData(eventData.getMap()); - - 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 + * @param event the event to track + * @return a list of eventIDs (UUIDs) */ - 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"); - - TrackerPayload 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); + 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; } - /** - * @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); + 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()); } - /** - * @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); - } + private void addContext(Event event, TrackerPayload payload) { + List entities = event.getContext(); - /** - * @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); + // 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); + } } /** - * @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 + * Builds the final event context. + * + * @param entities the base event context + * @return the final event context json with many entities inside */ - @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"); - - TrackerPayload 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); + private SelfDescribingJson getFinalContext(List entities) { + List> entityMaps = new LinkedList<>(); + for (SelfDescribingJson selfDescribingJson : entities) { + entityMaps.add(selfDescribingJson.getMap()); } - - addTrackerPayload(payload); + return new SelfDescribingJson(Constants.SCHEMA_CONTEXTS, entityMaps); } - /** - * @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); - } + private void addSubject(Event event, TrackerPayload payload) { + Subject eventSubject = event.getSubject(); - /** - * @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); + // Add subject if available + if (eventSubject != null) { + payload.addMap(new HashMap<>(eventSubject.getSubject())); + } else if (subject != null) { + payload.addMap(new HashMap<>(subject.getSubject())); + } } /** - * @param name The name of the screen view event - * @param id Screen view ID - * @param timestamp Optional user-provided timestamp for the event + * 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 trackScreenView(String name, String id, long timestamp) { - trackScreenView(name, id, null, timestamp); + public void close() { + emitter.close(); } - /** - * @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(Constants.SCHEMA_SCREEN_VIEW); - payload.setData(trackerPayload); - - trackUnstructuredEvent(payload, context, timestamp); - } } 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 7a56b84e..00000000 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/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; - -import com.snowplowanalytics.snowplow.tracker.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/src/main/java/com/snowplowanalytics/snowplow/tracker/Util.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/Util.java deleted file mode 100644 index 43e37784..00000000 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/Util.java +++ /dev/null @@ -1,71 +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.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import org.apache.commons.codec.binary.Base64; - -import java.io.IOException; -import java.util.Map; -import java.util.Random; -import java.util.UUID; - -public class Util { - private static ObjectMapper sObjectMapper = new ObjectMapper(); - - private static Base64 sBase64 = new Base64(true); // URL-safe variant - - 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) { - return sBase64.encodeBase64String(string.getBytes()); - } - - public static String getEventId() { - return UUID.randomUUID().toString(); - } -} 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 a75e033b..00000000 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/Version.java +++ /dev/null @@ -1,20 +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; - -// DO NOT EDIT. AUTO-GENERATED. -public class Version { - static final String TRACKER = "java-0.7.0"; - static final String VERSION = "0.7.0"; -} 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.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/constants/Constants.java similarity index 60% rename from src/main/java/com/snowplowanalytics/snowplow/tracker/Constants.java rename to src/main/java/com/snowplowanalytics/snowplow/tracker/constants/Constants.java index f34a4685..c2d573bd 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/Constants.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/constants/Constants.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,21 +10,27 @@ * "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; -package com.snowplowanalytics.snowplow.tracker; - +/** + * 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"; // Tracker Protocol v2 + public static final String PROTOCOL_VERSION = "tp2"; - public static final String SCHEMA_PAYLOAD_DATA = "iglu:com.snowplowanalytics.snowplow/payload_data/jsonschema/1-0-0"; - public static final String SCHEMA_CONTEXTS = "iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0"; - public static final String SCHEMA_UNSTRUCT_EVENT = "iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0"; + 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_UNSTRUCTURED = "ue"; + public static final String EVENT_SELF_DESCRIBING = "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/src/main/java/com/snowplowanalytics/snowplow/tracker/Parameter.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/constants/Parameter.java similarity index 72% rename from src/main/java/com/snowplowanalytics/snowplow/tracker/Parameter.java rename to src/main/java/com/snowplowanalytics/snowplow/tracker/constants/Parameter.java index 469eaf56..19ec1863 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/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; +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/BufferOption.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchPayload.java similarity index 52% rename from src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BufferOption.java rename to src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchPayload.java index d9e67f04..7cc0187c 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BufferOption.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchPayload.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,32 +10,34 @@ * "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; + /** - * BufferOption is used to set the buffer size of your Emitter. + * A wrapper for a number of TrackerPayloads. */ -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 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 getCode() { - return code; + 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 40c3baa0..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,253 +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.Constants; -import com.snowplowanalytics.snowplow.tracker.payload.Payload; -import com.snowplowanalytics.snowplow.tracker.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.*; -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(); +import java.util.List; - 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() { +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; - } - - /** - * 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); - } +/** + * Emitter interface. + */ +public interface Emitter { /** - * 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. + * 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 */ - public Emitter(String URI, HttpMethod httpMethod) { - this(URI, httpMethod, null); - } + boolean add(TrackerPayload payload); /** - * 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. + * Customize the emitter batch size to any valid integer + * greater than zero. + * + * @param batchSize number of events to collect before + * sending */ - 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.PROTOCOL_VENDOR + "/" + Constants.PROTOCOL_VERSION); - } - this.requestCallback = callback; - this.httpMethod = httpMethod; - this.httpClient = HttpClients.createDefault(); - - if (httpMethod == HttpMethod.GET) { - this.setBufferOption(BufferOption.Instant); - } - - } + void setBatchSize(int batchSize); /** - * 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. + * This can be used to manually send all buffered events. */ - public void setBufferOption(BufferOption option) { - this.option = option; - } + void flushBuffer(); /** - * Sets whether requests should be sent synchronously or asynchronously. - * @param option The HTTP request method + * Gets the Emitter Batch Size + * + * @return the batch size */ - public void setRequestMethod(RequestMethod option) { - this.requestMethod = option; - this.httpAsyncClient = HttpAsyncClients.createDefault(); - this.httpAsyncClient.start(); - } + int getBatchSize(); /** - * 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 + * Returns the List of Payloads that are in the buffer. + * + * @return the buffer events */ - public boolean addToBuffer(Payload payload) { - boolean ret = buffer.add(payload); - if (buffer.size() == option.getCode()) - flushBuffer(); - return ret; - } + List getBuffer(); /** - * Sends all events in the buffer to the collector. + * Safely shuts down the Emitter. */ - 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; - } + void close(); } diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/HttpMethod.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/EmitterCallback.java similarity index 55% rename from src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/HttpMethod.java rename to src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/EmitterCallback.java index 1204c163..6535b7f2 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/HttpMethod.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,19 +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; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +import java.util.List; + /** - * HttpMethod is used to set the request method for your Emitter (i.e. GET or POST requests). + * 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 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 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/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/RequestMethod.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/OkHttpClientWithCookieJarAdapter.java similarity index 51% rename from src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/RequestMethod.java rename to src/main/java/com/snowplowanalytics/snowplow/tracker/http/OkHttpClientWithCookieJarAdapter.java index e7a0cfd6..a8d05d70 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/RequestMethod.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,20 +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.emitter; +// SquareUp +import okhttp3.*; /** - * RequestMethod is used to choose how network requests should be sent. - * This can be used by setRequestMethod(RequestMethod option) to set accordingly. + * 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 RequestMethod { - /** - * Requests are sent synchronously, so it should be used with caution. - */ - Synchronous, - /** - * Requests are sent asynchronously using a background thread. - */ - Asynchronous +public class OkHttpClientWithCookieJarAdapter extends OkHttpClientAdapter { + + public OkHttpClientWithCookieJarAdapter(String url) { + super(url, new OkHttpClient.Builder().cookieJar(new CollectorCookieJar()).build()); + } + } diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/Payload.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/Payload.java index 642b5e36..31389e46 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/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,45 +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; -// Java - -import com.fasterxml.jackson.databind.JsonNode; - import java.util.Map; -// JSON - /** - * 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. - * - * It allows the code for buffering events for sending to be agnostic of the event - * format (either a TrackerPayload or a SchemaPayload). - * @version 0.5.0 - * @author Jonathan Almeida */ public interface Payload { + /** + * 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 + */ + void add(String key, String 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 + */ + 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 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 + */ + 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 d4bf5df2..00000000 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/SchemaPayload.java +++ /dev/null @@ -1,101 +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; - -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.Parameter; -import com.snowplowanalytics.snowplow.tracker.Util; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.common.base.Preconditions; - -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; - } - - 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/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 562538e7..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,139 +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; -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.Util; +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 java.io.IOException; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; +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 final ObjectMapper objectMapper = Util.defaultMapper(); - private final Logger LOGGER = LoggerFactory.getLogger(TrackerPayload.class); - private ObjectNode objectNode = objectMapper.createObjectNode(); + private static final Logger LOGGER = LoggerFactory.getLogger(TrackerPayload.class); + protected final Map payload = new LinkedHashMap<>(); + private final String eventId; + private final Long deviceCreatedTimestamp; - /** - * Add a basic parameter. - * @param key The parameter key - * @param value The parameter value as a String - */ - 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); + 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 basic parameter. - * @param key The parameter key - * @param value The parameter value + * 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 */ - // TODO: remove this. Tracker Payload is all Strings, - // so we shouldn't be passing in Objects - public void add(String key, Object value) { - if (value == null) { - LOGGER.debug("kv-value is empty. Returning out without adding key.."); + @Override + public void add(final String key, final String value) { + if (key == null || key.isEmpty()) { + LOGGER.error("Null or empty key detected"); return; } - - LOGGER.debug("Adding new key: {} with object value: {}", key, value); - try { - objectNode.putPOJO(key, objectMapper.writeValueAsString(value)); - } catch (JsonProcessingException e) { - e.printStackTrace(); + 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 - * add(String key, Object value) for each mapping for each key. - * @param map Mappings to be stored in this map + * 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 */ - // TODO: remove this unless we have a good reason to add - // a uni-typed Map - public void addMap(Map map) { - // Return if we don't have a map + @Override + public void addMap(final Map map) { if (map == null) { - LOGGER.debug("Map passed in is null. Returning without adding map.."); + 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)); + 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 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 + * 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 */ - public void addMap(Map map, Boolean base64_encoded, String type_encoded, String type_no_encoded) { - // Return if we don't have a map + @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.."); + 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 - } + final String mapString = Utils.mapToJSONString(map); + LOGGER.debug("Adding new map: {}", map); - 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); + if (base64Encoded) { + add(typeEncoded, Utils.base64Encode(mapString, StandardCharsets.UTF_8)); + } else { + add(typeNotEncoded, mapString); } } - public JsonNode getNode() { - return objectNode; + /** + * 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 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; + 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 objectNode.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/EmitterTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/EmitterTest.java deleted file mode 100644 index db1aadf7..00000000 --- a/src/test/java/com/snowplowanalytics/snowplow/tracker/EmitterTest.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.snowplowanalytics.snowplow.tracker; - -import com.snowplowanalytics.snowplow.tracker.emitter.*; -import com.snowplowanalytics.snowplow.tracker.payload.Payload; -import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; - -import com.github.tomakehurst.wiremock.junit.WireMockRule; -import org.junit.Rule; -import org.junit.Test; - -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; - -import static com.github.tomakehurst.wiremock.client.WireMock.*; - -public class EmitterTest { - - @Rule - public WireMockRule wireMockRule = new WireMockRule(); - - private static String testURL = "localhost:8080"; - - @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(); - - verify(getRequestedFor(urlEqualTo("/i?test=testFlushBuffer"))); - } - - @Test - public void testFlushPost() throws Exception { - Emitter emitter = new Emitter(testURL, HttpMethod.POST); - - TrackerPayload payload = new TrackerPayload(); - LinkedHashMap foo = new LinkedHashMap(); - ArrayList bar = new ArrayList(); - payload.add("someValue", "someKey"); - ArrayList anArray = new ArrayList(); - anArray.add("value1"); - anArray.add("value2"); - payload.add("values", anArray.toString()); - payload.addMap(foo); - - emitter.addToBuffer(payload); - - emitter.flushBuffer(); - - verify(postRequestedFor(urlEqualTo("/com.snowplowanalytics.snowplow/tp2")) - .withHeader("Content-Type", equalTo("application/json; charset=utf-8")) - .withRequestBody(equalToJson("{\"schema\":\"iglu:com.snowplowanalytics.snowplow/" + - "payload_data/jsonschema/1-0-0\",\"data\":[{\"someValue\":\"someKey\"," + - "\"values\":\"[value1, value2]\"}]}"))); - } - - @Test - public void testBufferOption() throws Exception { - Emitter emitter = new Emitter(testURL); - emitter.setBufferOption(BufferOption.Instant); - } - - @Test - public void testFlushBuffer() throws Exception { - - stubFor(get(urlEqualTo("/i?test=testFlushBuffer")) - .willReturn(aResponse() - .withStatus(200))); - - Emitter emitter = new Emitter(testURL, HttpMethod.GET, new RequestCallback() { - @Override - public void onSuccess(int successCount) { - System.out.println("Buffer length for successful 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(); - - verify(getRequestedFor(urlEqualTo("/i?test=testFlushBuffer"))); - } - - @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); - - verify(getRequestedFor(urlEqualTo("/i?test=testFlushBuffer"))); - } - } -} \ 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 index 8c26db7b..7af8f4b3 100644 --- a/src/test/java/com/snowplowanalytics/snowplow/tracker/SubjectTest.java +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/SubjectTest.java @@ -1,67 +1,109 @@ +/* + * 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 org.junit.Test; - +// 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() throws Exception { + public void testSetUserId() { Subject subject = new Subject(); subject.setUserId("user1"); assertEquals("user1", subject.getSubject().get("uid")); } @Test - public void testSetScreenResolution() throws Exception { + public void testSetScreenResolution() { Subject subject = new Subject(); subject.setScreenResolution(100, 150); assertEquals("100x150", subject.getSubject().get("res")); } @Test - public void testSetViewPort() throws Exception { + public void testSetViewPort() { Subject subject = new Subject(); subject.setViewPort(150, 100); assertEquals("150x100", subject.getSubject().get("vp")); } @Test - public void testSetColorDepth() throws Exception { + public void testSetColorDepth() { 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 { + public void testSetTimezone2() { Subject subject = new Subject(); subject.setTimezone("America/Toronto"); assertEquals("America/Toronto", subject.getSubject().get("tz")); } @Test - public void testSetLanguage() throws Exception { + public void testSetLanguage() { Subject subject = new Subject(); subject.setLanguage("EN"); assertEquals("EN", subject.getSubject().get("lang")); } @Test - public void testGetSubject() throws Exception { + 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(); + Map expected = new HashMap<>(); subject.setTimezone("America/Toronto"); subject.setUserId("user1"); @@ -70,4 +112,45 @@ public void testGetSubject() throws Exception { assertEquals(expected, subject.getSubject()); } -} \ No newline at end of file + + @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/TrackerPayloadTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerPayloadTest.java deleted file mode 100644 index 17d083b0..00000000 --- a/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerPayloadTest.java +++ /dev/null @@ -1,138 +0,0 @@ -package com.snowplowanalytics.snowplow.tracker; - -import com.snowplowanalytics.snowplow.tracker.payload.Payload; -import com.snowplowanalytics.snowplow.tracker.payload.SchemaPayload; -import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; -import org.junit.Test; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.Map; - -import static org.junit.Assert.assertEquals; - -public class TrackerPayloadTest { - - @Test - public void testAddString() throws Exception { - TrackerPayload payload = new TrackerPayload(); - payload.add("foo", "bar"); - - String res = "{\"foo\":\"bar\"}"; - assertEquals(res, payload.toString()); - } - - @Test - public void testAddObject() throws Exception { - TrackerPayload 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); - TrackerPayload 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); - TrackerPayload 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); - TrackerPayload 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/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerTest.java index 3a24fe64..c2bbbd36 100644 --- a/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerTest.java +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerTest.java @@ -1,300 +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 com.snowplowanalytics.snowplow.tracker.emitter.BufferOption; -import com.snowplowanalytics.snowplow.tracker.emitter.Emitter; -import com.snowplowanalytics.snowplow.tracker.emitter.HttpMethod; -import com.snowplowanalytics.snowplow.tracker.emitter.RequestMethod; -import com.snowplowanalytics.snowplow.tracker.payload.SchemaPayload; - -import com.github.tomakehurst.wiremock.junit.WireMockRule; -import org.skyscreamer.jsonassert.JSONCompareMode; -import org.junit.Assert; -import org.junit.Rule; -import org.junit.Test; - import java.util.*; +import static java.util.Collections.singletonList; -import static org.junit.Assert.assertEquals; -import static com.github.tomakehurst.wiremock.client.WireMock.*; - -public class TrackerTest { +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.*; - @Rule - public WireMockRule wireMockRule = new WireMockRule(); +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; - private static String TESTURL = "localhost:8080"; +public class TrackerTest { - @Test - public void testDefaultPlatform() throws Exception { - Emitter emitter = new Emitter(TESTURL, HttpMethod.POST); - Subject subject = new Subject(); - Tracker tracker = new Tracker(emitter, subject, "AF003", "cloudfront", false); - assertEquals(DevicePlatform.Desktop, tracker.getPlatform()); + 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() {} } - @Test - public void testSetPlatform() throws Exception { - Emitter emitter = new Emitter(TESTURL, HttpMethod.POST); - Subject subject = new Subject(); - Tracker tracker = new Tracker(emitter, subject, "AF003", "cloudfront", false); - tracker.setPlatform(DevicePlatform.ConnectedTV); - assertEquals(DevicePlatform.ConnectedTV, tracker.getPlatform()); + 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 testSetSubject() throws Exception { - TimeZone.setDefault(TimeZone.getTimeZone("Etc/UTC")); - Emitter emitter = new Emitter(TESTURL, HttpMethod.POST); - Subject s1 = new Subject(); - Tracker tracker = new Tracker(emitter, s1, "AF003", "cloudfront", false); - 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()); + 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 testSetSchema() throws Exception { - + 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 testTrackPageView() throws Exception { - + 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 testTrackPageView1() throws Exception { - + 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 testTrackPageView2() throws Exception { - + 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 testTrackPageView3() throws Exception { - Emitter emitter = new Emitter(TESTURL, HttpMethod.POST); - Subject subject = new Subject(); - subject.setTimezone("Etc/UTC"); - 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(); - - verify(postRequestedFor(urlEqualTo("/com.snowplowanalytics.snowplow/tp2")) - .withHeader("Content-Type", equalTo("application/json; charset=utf-8")) - .withRequestBody(equalToJson("{\"schema\":\"iglu:com.snowplowanalytics." + - "snowplow/payload_data/jsonschema/1-0-0\",\"data\":[{\"e\":\"pv\"," + - "\"url\":\"www.mypage.com\",\"page\":\"My Page\",\"refr\":" + - "\"www.me.com\",\"aid\":\"cloudfront\",\"tna\":\"AF003\"," + - "\"tv\":\"java-0.7.0\",\"co\":\"{\\\"schema\\\":" + - "\\\"iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0\\\"," + - "\\\"data\\\":[{\\\"schema\\\":\\\"iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0\\\"," + - "\\\"data\\\":{\\\"someContextKey\\\":\\\"testTrackPageView3\\\"}}]}\"," + - "\"tz\":\"Etc/UTC\",\"p\":\"pc\",\"vp\":\"320x480\"}]}", - JSONCompareMode.LENIENT))); + 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 testTrackStructuredEvent() throws Exception { - + 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 testTrackStructuredEvent1() throws Exception { - + 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 testTrackStructuredEvent2() throws Exception { - + 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 testTrackStructuredEvent3() throws Exception { - + 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 testTrackUnstructuredEvent() throws Exception { - + 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 testTrackUnstructuredEvent1() throws Exception { - + 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 testTrackUnstructuredEvent2() throws Exception { - + 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 testTrackUnstructuredEvent3() throws Exception { + 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 testTrackEcommerceTransactionItem() throws Exception { + 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 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(); - - // Verifying this JSON: - // { - // "schema": "iglu:com.snowplowanalytics.snowplow/payload_data/jsonschema/1-0-0", - // "data": [{ - // "e": "ti", - // "ti_id": "order-8", - // "ti_sk": "no_sku", - // "ti_nm": "Big Order", - // "ti_ca": "Food", - // "ti_pr": "34.0", - // "ti_qu": "1.0", - // "ti_cu": "USD", - // "tna": "AF003", - // "tv": "java-0.7.0", - // "dtm": "1414607597877", - // "co": "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0\",\"data\":[{\"schema\":\"iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0\",\"data\":{\"someContextKey\":\"testTrackPageView2\"}}]}" - // }, { - // "e": "tr", - // "tr_id": "order-7", - // "tr_tt": "25.0", - // "tr_af": "no_affiliate", - // "tr_tx": "0.0", - // "tr_sh": "0.0", - // "tr_ci": "Dover", - // "tr_st": "Delaware", - // "tr_co": "US", - // "tr_cu": "USD", - // "tna": "AF003", - // "tv": "java-0.7.0", - // "dtm": "1414607597877" - // }] - // } - verify(postRequestedFor(urlEqualTo("/com.snowplowanalytics.snowplow/tp2")) - .withHeader("Content-Type", equalTo("application/json; charset=utf-8")) - .withRequestBody(equalToJson("{\"schema\":\"iglu:com.snowplowanalytics.snowplow/payload_data/jsonschema/1-0-0\",\"data\":[{\"e\":\"ti\",\"ti_id\":\"order-8\",\"ti_sk\":\"no_sku\",\"ti_nm\":\"Big Order\",\"ti_ca\":\"Food\",\"ti_pr\":\"34.0\",\"ti_qu\":\"1.0\",\"ti_cu\":\"USD\",\"aid\":\"cloudfront\",\"tna\":\"AF003\",\"tv\":\"java-0.7.0\",\"co\":\"{\\\"schema\\\":\\\"iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0\\\",\\\"data\\\":[{\\\"schema\\\":\\\"iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0\\\",\\\"data\\\":{\\\"someContextKey\\\":\\\"testTrackPageView2\\\"}}]}\"},{\"e\":\"tr\",\"tr_id\":\"order-7\",\"tr_tt\":\"25.0\",\"tr_af\":\"no_affiliate\",\"tr_tx\":\"0.0\",\"tr_sh\":\"0.0\",\"tr_ci\":\"Dover\",\"tr_st\":\"Delaware\",\"tr_co\":\"US\",\"tr_cu\":\"USD\",\"aid\":\"cloudfront\",\"tna\":\"AF003\",\"tv\":\"java-0.7.0\"}]}", - JSONCompareMode.LENIENT))); + public void testGetTrackerVersion() { + Tracker tracker = new Tracker(new TrackerConfiguration("namespace", "an-app-id"), mockEmitter); + assertEquals("java-2.1.0", tracker.getTrackerVersion()); } @Test - public void testTrackEcommerceTransaction1() throws Exception { + 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 testTrackEcommerceTransaction2() throws Exception { + 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); - @Test - public void testTrackEcommerceTransaction3() throws Exception { + Subject s2 = new Subject(); + s2.setColorDepth(24); + tracker.setSubject(s2); - } + Map subjectPairs = new HashMap<>(); + subjectPairs.put("tz", "Etc/UTC"); + subjectPairs.put("cd", "24"); - @Test - public void testTrackScreenView() throws Exception { - Emitter emitter = new Emitter(TESTURL, HttpMethod.POST); - Subject subject = new Subject(); - subject.setTimezone("Etc/UTC"); - 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); - - // Verifying this JSON: - // { - // "schema": "iglu:com.snowplowanalytics.snowplow/payload_data/jsonschema/1-0-0", - // "data": [{ - // "e": "ue", - // "ue_pr": "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0\",\"data\":{\"id\":\"screen_1\"}}}", - // "tna": "AF003", - // "tv": "java-0.7.0", - // "co": "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/screen_view/jsonschema/1-0-0\",\"data\":[{\"schema\":\"iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0\",\"data\":{\"someContextKey\":\"testTrackPageView2\"}}]}", - // "tz": "Etc/UTC", - // "p": "pc", - // "vp": "320x480" - // }] - // } - verify(postRequestedFor(urlEqualTo("/com.snowplowanalytics.snowplow/tp2")) - .withHeader("Content-Type", equalTo("application/json; charset=utf-8")) - .withRequestBody(equalToJson("{\"schema\":\"iglu:com.snowplowanalytics.snowplow/" + - "payload_data/jsonschema/1-0-0\",\"data\":[{\"e\":\"ue\",\"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\\\":\\\"screen_1\\\"}}}\",\"aid\":\"cloudfront\"," + - "\"tna\":\"AF003\",\"tv\":\"java-0.7.0\",\"co\":\"{\\\"schema\\\":" + - "\\\"iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0\\\"," + - "\\\"data\\\":[{\\\"schema\\\":" + - "\\\"iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0\\\"," + - "\\\"data\\\":{\\\"someContextKey\\\":\\\"testTrackPageView2\\\"}}]}\"," + - "\"tz\":\"Etc/UTC\",\"p\":\"pc\",\"vp\":\"320x480\"}]}", - JSONCompareMode.LENIENT))); + assertEquals(subjectPairs, tracker.getSubject().getSubject()); } @Test - public void testTrackScreenView1() throws Exception { + public void testSetBase64Encoded() { + TrackerConfiguration trackerConfig = new TrackerConfiguration("AF003", "cloudfront").base64Encoded(false); + tracker = new Tracker(trackerConfig, mockEmitter); + assertFalse(tracker.getBase64Encoded()); } @Test - public void testTrackScreenView2() throws Exception { - + public void testSetAppId() { + Tracker tracker = new Tracker(new TrackerConfiguration("AF003", "an-app-id"), mockEmitter); + assertEquals("an-app-id", tracker.getAppId()); } @Test - public void testTrackScreenView3() throws Exception { + public void testSetNamespace() { + Tracker tracker = new Tracker(new TrackerConfiguration("namespace", "an-app-id"), mockEmitter); + assertEquals("namespace", tracker.getNamespace()); } -} \ No newline at end of file +} diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/UtilTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/UtilTest.java deleted file mode 100644 index 4d6dd290..00000000 --- a/src/test/java/com/snowplowanalytics/snowplow/tracker/UtilTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.snowplowanalytics.snowplow.tracker; - -import com.fasterxml.jackson.databind.JsonNode; -import org.junit.Test; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Map; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; - -// JSONassert -import org.skyscreamer.jsonassert.JSONAssert; -import org.json.JSONException; - -public class UtilTest { - @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() throws JSONException { - 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); - - // Have to stringify because JSONAssert works with json.org, not Jackson - JSONAssert.assertEquals("{\"list\":[\"some\",\"stuff\"],\"foo\":\"bar\"}", node.toString(), false); - } -} 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")); + } +} diff --git a/vagrant/.gitignore b/vagrant/.gitignore deleted file mode 100644 index d0e94a5b..00000000 --- a/vagrant/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -* -!.gitignore -!peru.yaml -!ansible.hosts -!up.bash -!up.playbooks -!up.guidance -!push.bash diff --git a/vagrant/ansible.hosts b/vagrant/ansible.hosts deleted file mode 100644 index 588fa08c..00000000 --- a/vagrant/ansible.hosts +++ /dev/null @@ -1,2 +0,0 @@ -[vagrant] -127.0.0.1:2222 diff --git a/vagrant/peru.yaml b/vagrant/peru.yaml deleted file mode 100644 index 053f6b0b..00000000 --- a/vagrant/peru.yaml +++ /dev/null @@ -1,14 +0,0 @@ -imports: - ansible: ansible - ansible_playbooks: oss-playbooks - -curl module ansible: - # Equivalent of git cloning tags/v1.6.6 but much, much faster - url: https://codeload.github.com/ansible/ansible/zip/69d85c22c7475ccf8169b6ec9dee3ee28c92a314 - build: unzip ansible-69d85c22c7475ccf8169b6ec9dee3ee28c92a314.zip - export: ansible-69d85c22c7475ccf8169b6ec9dee3ee28c92a314 - -git module ansible_playbooks: - url: https://github.com/snowplow/ansible-playbooks.git - # Comment out to fetch a specific rev instead of master: - # rev: xxx diff --git a/vagrant/push.bash b/vagrant/push.bash deleted file mode 100755 index df2ec16a..00000000 --- a/vagrant/push.bash +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -set -e - -echo "Not yet implemented, see https://github.com/snowplow/snowplow-java-tracker/issues/107" diff --git a/vagrant/up.bash b/vagrant/up.bash deleted file mode 100755 index 7450ae89..00000000 --- a/vagrant/up.bash +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/bash -set -e - -vagrant_dir=/vagrant/vagrant -bashrc=/home/vagrant/.bashrc - -echo "========================================" -echo "INSTALLING PERU AND ANSIBLE DEPENDENCIES" -echo "----------------------------------------" -apt-get update -apt-get install -y language-pack-en git unzip libyaml-dev python3-pip python-yaml python-paramiko python-jinja2 - -echo "===============" -echo "INSTALLING PERU" -echo "---------------" -sudo pip3 install peru - -echo "=======================================" -echo "CLONING ANSIBLE AND PLAYBOOKS WITH PERU" -echo "---------------------------------------" -cd ${vagrant_dir} && peru sync -v -echo "... done" - -env_setup=${vagrant_dir}/ansible/hacking/env-setup -hosts=${vagrant_dir}/ansible.hosts - -echo "===================" -echo "CONFIGURING ANSIBLE" -echo "-------------------" -touch ${bashrc} -echo "source ${env_setup}" >> ${bashrc} -echo "export ANSIBLE_HOSTS=${hosts}" >> ${bashrc} -echo "... done" - -echo "==========================================" -echo "RUNNING PLAYBOOKS WITH ANSIBLE*" -echo "* no output while each playbook is running" -echo "------------------------------------------" -while read pb; do - su - -c "source ${env_setup} && ${vagrant_dir}/ansible/bin/ansible-playbook ${vagrant_dir}/${pb} --connection=local --inventory-file=${hosts}" vagrant -done <${vagrant_dir}/up.playbooks - -guidance=${vagrant_dir}/up.guidance - -if [ -f ${guidance} ]; then - echo "===========" - echo "PLEASE READ" - echo "-----------" - cat $guidance -fi diff --git a/vagrant/up.guidance b/vagrant/up.guidance deleted file mode 100644 index c359beac..00000000 --- a/vagrant/up.guidance +++ /dev/null @@ -1,4 +0,0 @@ -To get started: -vagrant ssh -cd /vagrant -gradle test diff --git a/vagrant/up.playbooks b/vagrant/up.playbooks deleted file mode 100644 index 82d63689..00000000 --- a/vagrant/up.playbooks +++ /dev/null @@ -1,2 +0,0 @@ -oss-playbooks/java6.yml -oss-playbooks/gradle.yml