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 8be46618..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* @@ -54,6 +57,9 @@ local.properties # Ignoring Version.java since its auto-generated 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 9094ac7a..00000000 --- a/.travis.yml +++ /dev/null @@ -1,8 +0,0 @@ -sudo: false - -language: java - -jdk: - - openjdk7 - - oraclejdk7 - - oraclejdk8 diff --git a/CHANGELOG b/CHANGELOG index f5dd08e9..c750412a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,152 @@ +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) 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 ff2ac35a..839e8fd3 100644 --- a/README.md +++ b/README.md @@ -1,38 +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 JDK7+. +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$ ./gradlew clean build -guest$ ./gradlew 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 @@ -41,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]: 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/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 a1c030da..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,112 +11,91 @@ * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ -wrapper.gradleVersion = '2.6' +import java.time.Duration -buildscript { - repositories { - maven { url 'http://repo.spring.io/plugins-release' } - } - dependencies { - classpath 'org.springframework.build.gradle:propdeps-plugin:0.0.7' - } +plugins { + id 'java-library' + id 'maven-publish' + id 'io.github.gradle-nexus.publish-plugin' version '1.1.0' + id 'signing' + id 'idea' } -apply plugin: 'java' -apply plugin: 'maven-publish' -apply plugin: 'idea' -apply plugin: 'propdeps' -apply plugin: 'propdeps-maven' -apply plugin: 'propdeps-idea' +wrapper.gradleVersion = '6.5.0' group = 'com.snowplowanalytics' -version = '0.8.2' -sourceCompatibility = '1.7' -targetCompatibility = '1.7' +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() } configure([compileJava, compileTestJava]) { + sourceCompatibility = javaVersion + targetCompatibility = javaVersion options.encoding = 'UTF-8' } -configurations { - provided +java { + registerFeature('okhttpSupport') { + usingSourceSet(sourceSets.main) + } + registerFeature('apachehttpSupport') { + usingSourceSet(sourceSets.main) + } } -sourceSets { - main { - compileClasspath += configurations.provided +test { + useJUnitPlatform { + includeEngines 'junit-vintage' } } dependencies { - // Apache Commons - compile 'commons-codec:commons-codec:1.10' - 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 - optional 'org.apache.httpcomponents:httpclient:4.3.3' - optional 'org.apache.httpcomponents:httpasyncclient:4.0.1' - + apachehttpSupportApi 'org.apache.httpcomponents.client5:httpclient5:5.3' + // Square OK HTTP - optional 'com.squareup.okhttp:okhttp:2.2.0' - + 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:18.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' - testCompile 'org.mockito:mockito-core:1.9.5' - testCompile 'com.squareup.okhttp:mockwebserver:2.1.0' + 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-2015 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. @@ -127,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"; @@ -141,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/examples/benchmarking/build.gradle b/examples/benchmarking/build.gradle new file mode 100644 index 00000000..279b1a8a --- /dev/null +++ b/examples/benchmarking/build.gradle @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ + +import org.gradle.api.tasks.JavaExec + +plugins { + id 'java' + id "me.champeau.jmh" version "0.6.6" +} + +group 'com.snowplowanalytics' +version '1.0' + +repositories { + mavenLocal { + content { + includeGroup "com.snowplowanalytics" + } + } + mavenCentral() +} + + +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 3865acda..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-2.6-bin.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 8b06b116..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) 2015 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. @@ -17,153 +17,50 @@ import java.util.Map; // This library +import com.snowplowanalytics.snowplow.tracker.configuration.SubjectConfiguration; import com.snowplowanalytics.snowplow.tracker.constants.Parameter; /** * An object for managing extra event decoration. + * All the properties are optional. However, the timezone is set by default, + * to that of the server. */ public class Subject { private HashMap standardPairs = new HashMap<>(); /** - * Creates a Subject which will add extra data to each event. + * Creates a Subject instance from a SubjectConfiguration. * - * @param builder The builder that constructs a subject + * @param subjectConfig a SubjectConfiguration */ - private Subject(SubjectBuilder builder) { - this.setUserId(builder.userId); - this.setScreenResolution(builder.screenResWidth, builder.screenResHeight); - this.setViewPort(builder.viewPortWidth, builder.viewPortHeight); - this.setColorDepth(builder.colorDepth); - this.setTimezone(builder.timezone); - this.setLanguage(builder.language); - this.setIpAddress(builder.ipAddress); - this.setUseragent(builder.useragent); - this.setNetworkUserId(builder.networkUserId); - this.setDomainUserId(builder.domainUserId); + 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()); } /** - * Builder for the Subject + * Creates a Subject instance with default configuration (only the timezone is set). */ - public static class SubjectBuilder { - - private String userId; // Optional - private int screenResWidth = 0; // Optional - private int screenResHeight = 0; // Optional - private int viewPortWidth = 0; // Optional - private int viewPortHeight = 0; // Optional - private int colorDepth = 0; // Optional - private String timezone = Utils.getTimezone(); // Optional - private String language; // Optional - private String ipAddress; // Optional - private String useragent; // Optional - private String networkUserId; // Optional - private String domainUserId; // Optional - - /** - * @param userId a user id string - * @return itself - */ - public SubjectBuilder userId(String userId) { - this.userId = userId; - return this; - } - - /** - * @param width a width integer - * @param height a height integer - * @return itself - */ - public SubjectBuilder screenResolution(int width, int height) { - this.screenResWidth = width; - this.screenResHeight = height; - return this; - } - - /** - * @param width a width integer - * @param height a height integer - * @return itself - */ - public SubjectBuilder viewPort(int width, int height) { - this.viewPortWidth = width; - this.viewPortHeight = height; - return this; - } - - /** - * @param depth a color depth integer - * @return itself - */ - public SubjectBuilder colorDepth(int depth) { - this.colorDepth = depth; - return this; - } - - /** - * @param timezone a timezone string - * @return itself - */ - public SubjectBuilder timezone(String timezone) { - this.timezone = timezone; - return this; - } - - /** - * @param language a language string - * @return itself - */ - public SubjectBuilder language(String language) { - this.language = language; - return this; - } - - /** - * @param ipAddress a ipAddress string - * @return itself - */ - public SubjectBuilder ipAddress(String ipAddress) { - this.ipAddress = ipAddress; - return this; - } - - /** - * @param useragent a useragent string - * @return itself - */ - public SubjectBuilder useragent(String useragent) { - this.useragent = useragent; - return this; - } - - /** - * @param networkUserId a networkUserId string - * @return itself - */ - public SubjectBuilder networkUserId(String networkUserId) { - this.networkUserId = networkUserId; - return this; - } - - /** - * @param domainUserId a domainUserId string - * @return itself - */ - public SubjectBuilder domainUserId(String domainUserId) { - this.domainUserId = domainUserId; - return this; - } + public Subject() { + this(new SubjectConfiguration()); + } - /** - * Creates a new Subject - * - * @return a new Subject object - */ - public Subject build() { - return new Subject(this); - } + /** + * 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()); } /** @@ -177,6 +74,17 @@ public void setUserId(String 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 * @@ -190,6 +98,18 @@ public void setScreenResolution(int width, int height) { } } + /** + * 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 * @@ -203,6 +123,18 @@ public void setViewPort(int width, int height) { } } + /** + * 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 * @@ -215,7 +147,19 @@ public void setColorDepth(int depth) { } /** - * Sets the timezone parameter + * 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 */ @@ -225,6 +169,19 @@ public void setTimezone(String 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 * @@ -237,8 +194,18 @@ public void setLanguage(String language) { } /** - * User inputted ip address for the - * subject. + * 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 */ @@ -249,8 +216,18 @@ public void setIpAddress(String ipAddress) { } /** - * User inputted useragent for the - * subject. + * 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 */ @@ -261,8 +238,18 @@ public void setUseragent(String useragent) { } /** - * User inputted Domain User Id for the - * subject. + * 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 */ @@ -273,8 +260,41 @@ public void setDomainUserId(String domainUserId) { } /** - * User inputted Network User Id for the - * subject. + * 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 */ @@ -284,6 +304,18 @@ public void setNetworkUserId(String 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. * diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/Tracker.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/Tracker.java index 781dacf5..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) 2015 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. @@ -12,115 +12,69 @@ */ package com.snowplowanalytics.snowplow.tracker; -// Java -import java.util.*; - -// Google -import com.google.common.base.Preconditions; - -// This library +import com.snowplowanalytics.snowplow.tracker.configuration.TrackerConfiguration; import com.snowplowanalytics.snowplow.tracker.constants.Constants; import com.snowplowanalytics.snowplow.tracker.constants.Parameter; import com.snowplowanalytics.snowplow.tracker.emitter.Emitter; import com.snowplowanalytics.snowplow.tracker.events.*; import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerParameters; import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; +import java.util.*; + +/** + * Allows tracking of Events. + */ public class Tracker { - private final String trackerVersion = Version.TRACKER; private Emitter emitter; private Subject subject; - private String appId; - private String namespace; - private DevicePlatform platform; - private boolean base64Encoded; + private final TrackerParameters parameters; /** * Creates a new Snowplow Tracker. * - * @param builder The builder that constructs a tracker + * @param trackerConfig a TrackerConfiguration object + * @param emitter an Emitter + * @param subject a Subject + * */ - private Tracker(TrackerBuilder builder) { + public Tracker(TrackerConfiguration trackerConfig, Emitter emitter, Subject subject) { // Precondition checks - Preconditions.checkNotNull(builder.emitter); - Preconditions.checkNotNull(builder.namespace); - Preconditions.checkNotNull(builder.appId); - Preconditions.checkArgument(!builder.namespace.isEmpty(), "namespace cannot be empty"); - Preconditions.checkArgument(!builder.appId.isEmpty(), "appId cannot be empty"); - - this.emitter = builder.emitter; - this.namespace = builder.namespace; - this.appId = builder.appId; - this.subject = builder.subject; - this.platform = builder.platform; - this.base64Encoded = builder.base64Encoded; - } - - /** - * Builder for the Tracker - */ - public static class TrackerBuilder { - - private final Emitter emitter; // Required - private final String namespace; // Required - private final String appId; // Required - private Subject subject = null; // Optional - private DevicePlatform platform = DevicePlatform.ServerSideApp; // Optional - private boolean base64Encoded = true; // Optional - - /** - * @param emitter Emitter to which events will be sent - * @param namespace Identifier for the Tracker instance - * @param appId Application ID - */ - public TrackerBuilder(Emitter emitter, String namespace, String appId) { - this.emitter = emitter; - this.namespace = namespace; - this.appId = appId; + Objects.requireNonNull(emitter); + Objects.requireNonNull(trackerConfig.getNamespace()); + Objects.requireNonNull(trackerConfig.getAppId()); + if (trackerConfig.getNamespace().isEmpty()) { + throw new IllegalArgumentException("namespace cannot be empty"); } - - /** - * @param subject Subject to be tracked - * @return itself - */ - public TrackerBuilder subject(Subject subject) { - this.subject = subject; - return this; + if (trackerConfig.getAppId().isEmpty()) { + throw new IllegalArgumentException("appId cannot be empty"); } - /** - * @param platform The device platform the tracker is running on - * @return itself - */ - public TrackerBuilder platform(DevicePlatform platform) { - this.platform = platform; - return this; - } + this.parameters = new TrackerParameters(trackerConfig.getAppId(), trackerConfig.getPlatform(), trackerConfig.getNamespace(), Version.TRACKER, trackerConfig.isBase64Encoded()); + this.emitter = emitter; + this.subject = subject; - /** - * @param base64 Whether JSONs in the payload should be base-64 encoded - * @return itself - */ - public TrackerBuilder base64(Boolean base64) { - this.base64Encoded = base64; - return this; - } + } - /** - * Creates a new Tracker - * - * @return a new Tracker object - */ - public Tracker build() { - return new Tracker(this); - } + /** + * Creates a new Snowplow Tracker. + * + * @param trackerConfig a TrackerConfiguration object + * @param emitter an Emitter + * + */ + public Tracker(TrackerConfiguration trackerConfig, Emitter emitter) { + this(trackerConfig, emitter, null); } // --- Setters /** + * Change the Emitter used to send events. + * * @param emitter a new emitter */ public void setEmitter(Emitter emitter) { @@ -137,44 +91,6 @@ public void setSubject(Subject subject) { this.subject = subject; } - /** - * Sets the Trackers platform, defaults to a - * Server Side Application. - * - * @param platform the DevicePlatform - */ - public void setPlatform(DevicePlatform platform) { - this.platform = platform; - } - - /** - * Sets whether to base64 Encode custom contexts - * and unstructured events - * - * @param base64Encoded a boolean truth - */ - public void setBase64Encoded(boolean base64Encoded) { - this.base64Encoded = base64Encoded; - } - - /** - * Sets a new Application ID - * - * @param appId the new application id - */ - public void setAppId(String appId) { - this.appId = appId; - } - - /** - * Sets a new Tracker Namespace - * - * @param namespace the new tracker namespace - */ - public void setNamespace(String namespace) { - this.namespace = namespace; - } - // --- Getters /** @@ -185,148 +101,188 @@ public Emitter getEmitter() { } /** - * @return the Tracker Subject + * @return the Tracker-associated Subject */ public Subject getSubject() { return this.subject; } /** - * @return the tracker version that was set + * The Java tracker release version, e.g. 0.12.0. + * + * @return the tracker version */ public String getTrackerVersion() { - return this.trackerVersion; + return this.parameters.getTrackerVersion(); } /** * @return the trackers namespace */ public String getNamespace() { - return this.namespace; + return this.parameters.getNamespace(); } /** - * @return the trackers set Application ID + * @return the tracker Application ID */ public String getAppId() { - return this.appId; + return this.parameters.getAppId(); } /** * @return the base64 setting of the tracker */ public boolean getBase64Encoded() { - return this.base64Encoded; + return this.parameters.getBase64Encoded(); } /** - * @return the Tracker platform + * @return the Tracker platform, e.g. "srv" */ public DevicePlatform getPlatform() { - return this.platform; + return this.parameters.getPlatform(); } - // --- Event Tracking Functions - /** - * Used for either Tracking a custom TrackerPayload or - * for re-sending a failed event. - * - * @param payload the payload to track + * @return the wrapper containing the Tracker parameters */ - public void track(TrackerPayload payload) { - this.emitter.emit(payload); + public TrackerParameters getParameters() { + return parameters; } + // --- Event Tracking Functions + /** - * Handles tracking the different types of events that - * the Tracker can encounter. + * Handles tracking the different types of events. + * + * A TrackerPayload object - or more than one, in the case of eCommerceTransaction events - + * will be created from the Event. This is passed to the configured Emitter. + * If the event was successfully added to the Emitter buffer for sending, + * a list containing the payload's eventId string (a UUID) is returned. + * EcommerceTransactions will return all the relevant eventIds in the list. + * If the Emitter event buffer is full, the payload will be lost. In this case, this method + * returns a list containing null. + *

+ * Implementation note: As a side effect of adding a payload to the Emitter, + * it triggers an Emitter thread to emit a batch of events. * * @param event the event to track + * @return a list of eventIDs (UUIDs) */ - public void track(Event event) { - List context = event.getContext(); - Subject subject = event.getSubject(); + 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; + } - // Figure out what type of event it is and track it! - Class eClass = event.getClass(); - if (eClass.equals(PageView.class) || eClass.equals(Structured.class)) { - this.addTrackerPayload((TrackerPayload) event.getPayload(), context, subject); - } else if (eClass.equals(EcommerceTransaction.class)) { - this.addTrackerPayload((TrackerPayload) event.getPayload(), context, subject); + 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(); - // Track each item individually - EcommerceTransaction ecommerceTransaction = (EcommerceTransaction) event; - for(EcommerceTransactionItem item : ecommerceTransaction.getItems()) { - item.setTimestamp(ecommerceTransaction.getTimestamp()); - this.addTrackerPayload(item.getPayload(), item.getContext(), item.getSubject()); - } - } else if (eClass.equals(Unstructured.class)) { + 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); - // Need to set the Base64 rule for Unstructured events - Unstructured unstructured = (Unstructured) event; - unstructured.setBase64Encode(base64Encoded); - this.addTrackerPayload(unstructured.getPayload(), context, subject); - } else if (eClass.equals(Timing.class) || eClass.equals(ScreenView.class)) { + } else if (eventClass.equals(EcommerceTransaction.class)) { + final EcommerceTransaction ecommerceTransaction = (EcommerceTransaction) event; + eventList.add(ecommerceTransaction); + + // Track each item individually + eventList.addAll(ecommerceTransaction.getItems()); - // These are wrapper classes for Unstructured events; need to create Unstructured - // events from them and resend. - this.track(Unstructured.builder() + } 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(context) - .timestamp(event.getTimestamp()) - .eventId(event.getEventId()) - .subject(subject) - .build()); + .customContext(event.getContext()) + .trueTimestamp(event.getTrueTimestamp()) + .subject(event.getSubject()) + .build(); + + selfDescribing.setBase64Encode(parameters.getBase64Encoded()); + eventList.add(selfDescribing); + + } else { + eventList.add(event); } + return eventList; } - // --- Helpers + private void addTrackerParameters(TrackerPayload payload) { + payload.add(Parameter.PLATFORM, parameters.getPlatform().toString()); + payload.add(Parameter.APP_ID, parameters.getAppId()); + payload.add(Parameter.NAMESPACE, parameters.getNamespace()); + payload.add(Parameter.TRACKER_VERSION, parameters.getTrackerVersion()); + } + + private void addContext(Event event, TrackerPayload payload) { + List entities = event.getContext(); + + // Build the final context and add it to the payload + if (entities != null && entities.size() > 0) { + SelfDescribingJson envelope = getFinalContext(entities); + payload.addMap(envelope.getMap(), parameters.getBase64Encoded(), Parameter.CONTEXT_ENCODED, Parameter.CONTEXT); + } + } /** - * Builds and Adds a finalised payload which is ready for sending. + * Builds the final event context. * - * @param payload The raw event Payload - * @param contexts Custom context for the event - * @param eventSubject An optional event specific Subject + * @param entities the base event context + * @return the final event context json with many entities inside */ - private void addTrackerPayload(TrackerPayload payload, List contexts, Subject eventSubject) { - - // Add default parameters to the payload - payload.add(Parameter.PLATFORM, platform.toString()); - payload.add(Parameter.APP_ID, this.appId); - payload.add(Parameter.NAMESPACE, this.namespace); - payload.add(Parameter.TRACKER_VERSION, this.trackerVersion); - - // Build the final context and add it to the payload - if (contexts != null && contexts.size() > 0) { - SelfDescribingJson envelope = getFinalContext(contexts); - payload.addMap(envelope.getMap(), this.base64Encoded, Parameter.CONTEXT_ENCODED, Parameter.CONTEXT); + private SelfDescribingJson getFinalContext(List entities) { + List> entityMaps = new LinkedList<>(); + for (SelfDescribingJson selfDescribingJson : entities) { + entityMaps.add(selfDescribingJson.getMap()); } + return new SelfDescribingJson(Constants.SCHEMA_CONTEXTS, entityMaps); + } + + private void addSubject(Event event, TrackerPayload payload) { + Subject eventSubject = event.getSubject(); // Add subject if available if (eventSubject != null) { payload.addMap(new HashMap<>(eventSubject.getSubject())); - } else if (this.subject != null) { - payload.addMap(new HashMap<>(this.subject.getSubject())); + } else if (subject != null) { + payload.addMap(new HashMap<>(subject.getSubject())); } - - // Send the event! - this.emitter.emit(payload); } /** - * Builds the final event context. - * - * @param contexts the base event context - * @return the final event context json with - * many contexts inside + * 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(). */ - private SelfDescribingJson getFinalContext(List contexts) { - List contextMaps = new LinkedList<>(); - for (SelfDescribingJson selfDescribingJson : contexts) { - contextMaps.add(selfDescribingJson.getMap()); - } - return new SelfDescribingJson(Constants.SCHEMA_CONTEXTS, contextMaps); + public void close() { + emitter.close(); } + } diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/Utils.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/Utils.java index b01d8583..af7e94a3 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/Utils.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/Utils.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -12,29 +12,29 @@ */ package com.snowplowanalytics.snowplow.tracker; -// Java +import java.nio.charset.Charset; import java.util.*; import java.net.URL; import java.net.URLEncoder; -// Jackson import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -// Slf4j +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -// Apache -import static org.apache.commons.codec.binary.Base64.encodeBase64String; - /** * 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(); + private static final ObjectMapper objectMapper + = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); // Tracker Utils @@ -82,7 +82,8 @@ public static boolean isValidUrl(String url) { new URL(url).toURI(); return true; } catch (Exception e) { - LOGGER.error("URI {} is not valid: {}", url, e.getMessage()); + LOGGER.error("Invalid URI"); + LOGGER.debug("URI {} is not valid: {}", url, e.getMessage()); return false; } } @@ -106,10 +107,11 @@ public static String getTimezone() { * 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) { - return encodeBase64String(string.getBytes()); + public static String base64Encode(String string, Charset charset) { + return Base64.getEncoder().encodeToString(string.getBytes(charset)); } /** @@ -119,12 +121,13 @@ public static String base64Encode(String string) { * @param map the map to process into a JSON String * @return the final JSON String */ - public static String mapToJSONString(Map map) { + 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: {}", map, e.getMessage()); + LOGGER.error("Could not process Map into JSON String"); + LOGGER.debug("Could not process Map {} into JSON String: {}", map, e.getMessage()); } return jString; } @@ -135,7 +138,7 @@ public static String mapToJSONString(Map map) { * @param map The map to convert * @return the QueryString ready for sending */ - public static String mapToQueryString(Map map) { + public static String mapToQueryString(Map map) { StringBuilder sb = new StringBuilder(); for (String key : map.keySet()) { if (sb.length() > 0) { @@ -146,7 +149,7 @@ public static String mapToQueryString(Map map) { String encodedVal = urlEncodeUTF8(map.get(key)); // Do not add empty Keys - if (encodedKey != null && !encodedKey.isEmpty()) { + if (!encodedKey.isEmpty()) { sb.append(String.format("%s=%s", encodedKey, encodedVal)); } } @@ -167,7 +170,8 @@ public static String urlEncodeUTF8(Object o) { String encoded = URLEncoder.encode(s, "UTF-8"); return encoded.replaceAll("\\+", "%20"); } catch (Exception e) { - LOGGER.error("Object {} could not be encoded: {}", o, e.getMessage()); + LOGGER.error("Object could not be encoded"); + LOGGER.debug("Object {} could not be encoded: {}", o, e.getMessage()); return ""; } } diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/EmitterConfiguration.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/EmitterConfiguration.java new file mode 100644 index 00000000..e663db54 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/EmitterConfiguration.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.configuration; + +import com.snowplowanalytics.snowplow.tracker.emitter.EmitterCallback; +import com.snowplowanalytics.snowplow.tracker.emitter.EventStore; + +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; + +public class EmitterConfiguration { + + private int batchSize; // Optional + private int bufferCapacity; // Optional + private EventStore eventStore; // Optional + private Map customRetryForStatusCodes; // Optional + private int threadCount; // Optional + private ScheduledExecutorService requestExecutorService; // Optional + private EmitterCallback callback; // Optional + + // Getters and Setters + + /** + * Returns the number of events to send per request (batched). + * @return the batch size + */ + public int getBatchSize() { + return batchSize; + } + + /** + * Returns the maximum number of events to buffer in memory. + * @return maximum buffer capacity + */ + public int getBufferCapacity() { + return bufferCapacity; + } + + /** + * Returns the EventStore used to buffer events. + * @return EventStore instance + */ + public EventStore getEventStore() { + return eventStore; + } + + /** + * Returns the custom configuration for HTTP status codes. "True" means the + * @return map of integers (status codes) to booleans (true for retry and false for not retry) + */ + public Map getCustomRetryForStatusCodes() { + return customRetryForStatusCodes; + } + + /** + * Returns the number of threads used for event sending using the ScheduledExecutorService. + * @return thread count + */ + public int getThreadCount() { + return threadCount; + } + + /** + * Returns the ScheduledExecutorService used for sending events. + * @return ScheduledExecutorService object + */ + public ScheduledExecutorService getRequestExecutorService() { + return requestExecutorService; + } + + /** + * Returns the custom callback which is called when events are successfully sent to the collector, + * or after certain failure conditions. + * + * @return EmitterCallback object + */ + public EmitterCallback getCallback() { + return callback; + } + + // Constructor + + /** + * Create an EmitterConfiguration instance. The default configuration is: + * 50 batched events per request; + * maximum 10 000 events buffered in memory; + * 50 threads; + * no retry for request status codes 400, 401, 403, 410 or 422; + * and OkHttp (OkHttpClientAdapter) used for HTTP requests. + */ + public EmitterConfiguration() { + batchSize = 50; + bufferCapacity = 10000; + eventStore = null; + customRetryForStatusCodes = null; + threadCount = 50; + requestExecutorService = null; + callback = null; + } + + // Builder methods + + /** + * The default batch size is 50. + * + * @param batchSize The count of events to send in one HTTP request + * @return itself + */ + public EmitterConfiguration batchSize(int batchSize) { + this.batchSize = batchSize; + return this; + } + + /** + * The default buffer capacity is 10 000 events. + * When the buffer is full (due to network outage), new events are lost. + * + * @param bufferCapacity The maximum capacity of the default InMemoryEventStore event buffer + * @return itself + */ + public EmitterConfiguration bufferCapacity(int bufferCapacity) { + this.bufferCapacity = bufferCapacity; + return this; + } + + /** + * The default EventStore is InMemoryEventStore. + * + * @param eventStore The EventStore to use + * @return itself + */ + public EmitterConfiguration eventStore(EventStore eventStore) { + this.eventStore = eventStore; + return this; + } + + /** + * Set custom retry rules for HTTP status codes received in emit responses from the Collector. + * By default, retry will not occur for status codes 400, 401, 403, 410 or 422. This can be overridden here. + * Note that 2xx codes will never retry as they are considered successful. + * @param customRetryForStatusCodes Mapping of integers (status codes) to booleans (true for retry and false for not retry) + * @return itself + */ + public EmitterConfiguration customRetryForStatusCodes(Map customRetryForStatusCodes) { + this.customRetryForStatusCodes = customRetryForStatusCodes; + return this; + } + + /** + * Sets the Thread Count for the ScheduledExecutorService (default is 50). + * + * @param threadCount the size of the thread pool + * @return itself + */ + public EmitterConfiguration threadCount(int threadCount) { + this.threadCount = threadCount; + return this; + } + + /** + * Set a custom ScheduledExecutorService to send http requests (default is ScheduledThreadPoolExecutor). + *

+ * Implementation note: Be aware that calling `close()` on a BatchEmitter instance + * has a side-effect and will shutdown that ExecutorService. + * + * @param requestExecutorService the ScheduledExecutorService to use + * @return itself + */ + public EmitterConfiguration requestExecutorService(ScheduledExecutorService requestExecutorService) { + this.requestExecutorService = requestExecutorService; + return this; + } + + /** + * Provide a custom EmitterCallback to access successfully sent or failed event payloads. + * + * @param callback an EmitterCallback + * @return itself + */ + public EmitterConfiguration callback(EmitterCallback callback) { + this.callback = callback; + return this; + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/NetworkConfiguration.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/NetworkConfiguration.java new file mode 100644 index 00000000..7c33d101 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/NetworkConfiguration.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.configuration; + +import com.snowplowanalytics.snowplow.tracker.http.HttpClientAdapter; + + +public class NetworkConfiguration { + + private HttpClientAdapter httpClientAdapter = null; // Optional + private String collectorUrl = null; // Required if not specifying a httpClientAdapter + + // Getters and Setters + + /** + * Returns the HttpClientAdapter used. + * @return HttpClientAdapter object + */ + public HttpClientAdapter getHttpClientAdapter() { + return httpClientAdapter; + } + + /** + * Returns the event collector URL endpoint. + * @return collector URL + */ + public String getCollectorUrl() { + return collectorUrl; + } + + // Constructors + + /** + * Create a NetworkConfiguration instance and specify a custom HttpClientAdapter to use + * (the default is OkHttpClientAdapter). + * + * @param httpClientAdapter the adapter to use + */ + public NetworkConfiguration(HttpClientAdapter httpClientAdapter) { + this.httpClientAdapter = httpClientAdapter; + } + + /** + * Create a NetworkConfiguration instance with a collector endpoint URL. The URL will be used + * to create the default OkHttpClientAdapter. + * + * @param collectorUrl the url for the default httpClientAdapter + */ + public NetworkConfiguration(String collectorUrl) { + this.collectorUrl = collectorUrl; + } + + // Builder methods + + /** + * Sets a custom HttpClientAdapter (default is OkHttpClientAdapter). + * + * @param httpClientAdapter the adapter to use + * @return itself + */ + public NetworkConfiguration httpClientAdapter(HttpClientAdapter httpClientAdapter) { + this.httpClientAdapter = httpClientAdapter; + return this; + } + + /** + * Sets the endpoint url for when a httpClientAdapter is not specified. + * It will be used to create the default OkHttpClientAdapter. + * + * @param collectorUrl the url for the default httpClientAdapter + * @return itself + */ + public NetworkConfiguration collectorUrl(String collectorUrl) { + this.collectorUrl = collectorUrl; + return this; + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/SubjectConfiguration.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/SubjectConfiguration.java new file mode 100644 index 00000000..2b4eebce --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/SubjectConfiguration.java @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.configuration; + +import com.snowplowanalytics.snowplow.tracker.Utils; + +public class SubjectConfiguration { + + private String userId; // Optional + private int screenResWidth; // Optional + private int screenResHeight; // Optional + private int viewPortWidth; // Optional + private int viewPortHeight; // Optional + private int colorDepth; // Optional + private String timezone; // Optional + private String language; // Optional + private String ipAddress; // Optional + private String useragent; // Optional + private String networkUserId; // Optional + private String domainUserId; // Optional + private String domainSessionId; // Optional + + // Getters and Setters + + /** + * Returns the user ID. + * @return user ID + */ + public String getUserId() { + return userId; + } + + /** + * Returns the screen resolution width, in pixels. + * @return screen width + */ + public int getScreenResWidth() { + return screenResWidth; + } + + /** + * Returns the screen resolution height, in pixels. + * @return screen height + */ + public int getScreenResHeight() { + return screenResHeight; + } + + /** + * Returns the viewport width, in pixels. + * @return viewport width + */ + public int getViewPortWidth() { + return viewPortWidth; + } + + /** + * Returns the viewport height, in pixels. + * @return viewport height + */ + public int getViewPortHeight() { + return viewPortHeight; + } + + /** + * Returns the color depth. + * @return color depth + */ + public int getColorDepth() { + return colorDepth; + } + + /** + * Returns the timezone. Automatically set by default to that of the server. + * @return timezone + */ + public String getTimezone() { + return timezone; + } + + /** + * Returns the device language. + * @return language + */ + public String getLanguage() { + return language; + } + + /** + * Returns the IP address. + * @return IP address + */ + public String getIpAddress() { + return ipAddress; + } + + /** + * Returns the useragent. + * @return useragent + */ + public String getUseragent() { + return useragent; + } + + /** + * Returns the network user ID (UUID string). + * @return network user ID + */ + public String getNetworkUserId() { + return networkUserId; + } + + /** + * Returns the domain user ID (UUID string). + * @return domain user ID + */ + public String getDomainUserId() { + return domainUserId; + } + + /** + * Returns the domain session ID (UUID string). + * @return domain session ID + */ + public String getDomainSessionId() { + return domainSessionId; + } + + // Constructor + + /** + * Create a Subject instance. By default, timezone is set to the server's timezone. + */ + public SubjectConfiguration() { + userId = null; // Optional + screenResWidth = 0; // Optional + screenResHeight = 0; // Optional + viewPortWidth = 0; // Optional + viewPortHeight = 0; // Optional + colorDepth = 0; // Optional + timezone = Utils.getTimezone(); // Optional + language = null; // Optional + ipAddress = null; // Optional + useragent = null; // Optional + networkUserId = null; // Optional + domainUserId = null; // Optional + domainSessionId = null; // Optional + } + + // Builder methods + + /** + * Set a unique user ID. + * @param userId a user ID + * @return itself + */ + public SubjectConfiguration userId(String userId) { + this.userId = userId; + return this; + } + + /** + * Set the screen resolution. + * @param width width in pixels + * @param height height in pixels + * @return itself + */ + public SubjectConfiguration screenResolution(int width, int height) { + screenResWidth = width; + screenResHeight = height; + return this; + } + + /** + * Set the viewport size. + * @param width width in pixels + * @param height height in pixels + * @return itself + */ + public SubjectConfiguration viewPort(int width, int height) { + viewPortWidth = width; + viewPortHeight = height; + return this; + } + + /** + * @param depth a color depth integer + * @return itself + */ + public SubjectConfiguration colorDepth(int depth) { + colorDepth = depth; + return this; + } + + /** + * Note that timezone is set by default to the server's timezone + * (`TimeZone tz = Calendar.getInstance().getTimeZone().getID()`) + * @param timezone a timezone string + * @return itself + */ + public SubjectConfiguration timezone(String timezone) { + this.timezone = timezone; + return this; + } + + /** + * @param language a language string + * @return itself + */ + public SubjectConfiguration language(String language) { + this.language = language; + return this; + } + + /** + * @param ipAddress a ipAddress string + * @return itself + */ + public SubjectConfiguration ipAddress(String ipAddress) { + this.ipAddress = ipAddress; + return this; + } + + /** + * @param useragent a useragent string + * @return itself + */ + public SubjectConfiguration useragent(String useragent) { + this.useragent = useragent; + return this; + } + + /** + * This overrides the network user ID set by the Collector in response Cookies. + * @param networkUserId a networkUserId string + * @return itself + */ + public SubjectConfiguration networkUserId(String networkUserId) { + this.networkUserId = networkUserId; + return this; + } + + /** + * @param domainUserId a domainUserId string + * @return itself + */ + public SubjectConfiguration domainUserId(String domainUserId) { + this.domainUserId = domainUserId; + return this; + } + + /** + * @param domainSessionId a domainSessionId string + * @return itself + */ + public SubjectConfiguration domainSessionId(String domainSessionId) { + this.domainSessionId = domainSessionId; + return this; + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/TrackerConfiguration.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/TrackerConfiguration.java new file mode 100644 index 00000000..8774e259 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/configuration/TrackerConfiguration.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.configuration; + +import com.snowplowanalytics.snowplow.tracker.DevicePlatform; + + +public class TrackerConfiguration { + private final String namespace; // Required + private final String appId; // Required + private DevicePlatform platform; // Optional + private boolean base64Encoded; // Optional + + // Getters and Setters + + /** + * Returns the unique tracker namespace. + * @return tracker namespace + */ + public String getNamespace() { + return namespace; + } + + /** + * Returns the application ID. + * @return application ID + */ + public String getAppId() { + return appId; + } + + /** + * Returns the DevicePlatform for the tracker. + * @return what platform the app is running on + */ + public DevicePlatform getPlatform() { + return platform; + } + + /** + * Returns whether JSONs in the payload are base-64 encoded. + * @return true if encoded + */ + public boolean isBase64Encoded() { + return base64Encoded; + } + + // Constructor + + /** + * Create a TrackerConfiguration instance. The namespace is the unique identifier for the instance. + * By default, the platform is ServerSideApp, and JSONs will be base64 encoded. + * + * @param namespace identifier for the Tracker instance + * @param appId application ID + */ + public TrackerConfiguration(String namespace, String appId) { + this.namespace = namespace; + this.appId = appId; + this.platform = DevicePlatform.ServerSideApp; + this.base64Encoded = true; + } + + // Builder methods + + /** + * The {@link DevicePlatform} the tracker is running on (default is "srv", ServerSideApp). + * + * @param platform The device platform the tracker is running on + * @return itself + */ + public TrackerConfiguration platform(DevicePlatform platform) { + this.platform = platform; + return this; + } + + /** + * Whether JSONs in the payload should be base-64 encoded (default is true) + * + * @param base64Encoded JSONs should be encoded or not + * @return itself + */ + public TrackerConfiguration base64Encoded(boolean base64Encoded) { + this.base64Encoded = base64Encoded; + return this; + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/constants/Constants.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/constants/Constants.java index a1d946f2..c2d573bd 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/constants/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. @@ -13,16 +13,16 @@ package com.snowplowanalytics.snowplow.tracker.constants; /** - * Constants which apply to schemas, event types + * Constants that apply to schemas, event types * and sending protocols. */ public class Constants { public static final String PROTOCOL_VENDOR = "com.snowplowanalytics.snowplow"; public static final String PROTOCOL_VERSION = "tp2"; - public static final String SCHEMA_PAYLOAD_DATA = "iglu:com.snowplowanalytics.snowplow/payload_data/jsonschema/1-0-3"; + 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_UNSTRUCT_EVENT = "iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0"; + 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"; @@ -30,7 +30,7 @@ public class Constants { 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"; } diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/constants/Parameter.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/constants/Parameter.java index b026d8a3..19ec1863 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/constants/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. @@ -13,13 +13,22 @@ 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 APP_ID = "aid"; public static final String NAMESPACE = "tna"; @@ -27,8 +36,8 @@ public class Parameter { 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"; @@ -41,6 +50,7 @@ public class Parameter { 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"; diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/AbstractEmitter.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/AbstractEmitter.java deleted file mode 100644 index 46ccfc2d..00000000 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/AbstractEmitter.java +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright (c) 2015 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; - -// Java -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -// Google -import com.google.common.base.Preconditions; - -// This library -import com.snowplowanalytics.snowplow.tracker.http.HttpClientAdapter; -import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; - -/** - * AbstractEmitter class which contains common elements to - * the emitters wrapped in a builder format. - */ -public abstract class AbstractEmitter implements Emitter { - - protected HttpClientAdapter httpClientAdapter; - protected RequestCallback requestCallback; - protected ExecutorService executor; - protected List buffer = new ArrayList<>(); - protected int bufferSize = 1; - - public static abstract class Builder> { - - private HttpClientAdapter httpClientAdapter; // Required - private RequestCallback requestCallback = null; // Optional - private int threadCount = 50; // Optional - protected abstract T self(); - - /** - * Adds the HttpClientAdapter to the AbstractEmitter - * - * @param httpClientAdapter the adapter to use - * @return itself - */ - public T httpClientAdapter(HttpClientAdapter httpClientAdapter) { - this.httpClientAdapter = httpClientAdapter; - return self(); - } - - /** - * An optional Request Callback for adding the ability to - * handle failure cases for sending. - * - * @param requestCallback the emitter request callback - * @return itself - */ - public T requestCallback(RequestCallback requestCallback) { - this.requestCallback = requestCallback; - return self(); - } - - /** - * Sets the Thread Count for the ExecutorService - * - * @param threadCount the size of the thread pool - * @return itself - */ - public T threadCount(int threadCount) { - this.threadCount = threadCount; - return self(); - } - } - - private static class Builder2 extends Builder { - @Override - protected Builder2 self() { - return this; - } - } - - public static Builder builder() { - return new Builder2(); - } - - protected AbstractEmitter(Builder builder) { - - // Precondition checks - Preconditions.checkNotNull(builder.httpClientAdapter); - Preconditions.checkArgument(builder.threadCount > 0, "threadCount must be greater than 0"); - - this.httpClientAdapter = builder.httpClientAdapter; - this.requestCallback = builder.requestCallback; - this.executor = Executors.newScheduledThreadPool(builder.threadCount); - } - - /** - * Adds a payload to the buffer and checks whether - * we have reached the buffer limit yet. - * - * @param payload an event payload - */ - @Override - public abstract void emit(TrackerPayload payload); - - /** - * Customize the emitter buffer size to any valid integer - * greater than zero. - * - Will only effect the BatchEmitter - * - * @param bufferSize number of events to collect before - * sending - */ - @Override - public void setBufferSize(int bufferSize) { - Preconditions.checkArgument(bufferSize > 0, "bufferSize must be greater than 0"); - this.bufferSize = bufferSize; - } - - /** - * When the buffer limit is reached sending of the buffer is - * initiated. - */ - @Override - public abstract void flushBuffer(); - - /** - * Gets the Emitter Buffer Size - * - Will always be 1 for SimpleEmitter - * - * @return the buffer size - */ - @Override - public int getBufferSize() { - return this.bufferSize; - } - - /** - * Returns the List of Payloads that are in the buffer. - * - * @return the buffer payloads - */ - @Override - public List getBuffer() { - return this.buffer; - } - - /** - * Sends a runnable to the executor service. - * - * @param runnable the runnable to be queued - */ - protected void execute(Runnable runnable) { - this.executor.execute(runnable); - } - - /** - * 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(int code) { - return code >= 200 && code < 300; - } -} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchEmitter.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchEmitter.java index 3a069be6..9c2e7a13 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchEmitter.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchEmitter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -12,127 +12,285 @@ */ package com.snowplowanalytics.snowplow.tracker.emitter; -// Java import java.io.Closeable; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; +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; -// Google -import com.google.common.base.Preconditions; +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; -// Slf4j import org.slf4j.Logger; import org.slf4j.LoggerFactory; -// This library -import com.snowplowanalytics.snowplow.tracker.constants.Constants; -import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; -import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; - /** - * An emitter that emit a batch of events in a single call - * It uses the post method of under-laying http adapter + * 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 extends AbstractEmitter implements Closeable { +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; - public static abstract class Builder> extends AbstractEmitter.Builder { + private final HttpClientAdapter httpClientAdapter; + private final ScheduledExecutorService executor; + private final EventStore eventStore; + private final Map customRetryForStatusCodes; + private final EmitterCallback callback; - private int bufferSize = 50; // Optional + /** + * 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(); - /** - * @param bufferSize The count of events to buffer before sending - * @return itself - */ - public T bufferSize(int bufferSize) { - this.bufferSize = bufferSize; - return self(); + 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) {} + }; } - public BatchEmitter build() { - return new BatchEmitter(this); + if (emitterConfig.getEventStore() != null) { + eventStore = emitterConfig.getEventStore(); + } else { + eventStore = new InMemoryEventStore(emitterConfig.getBufferCapacity()); } - } - private static class Builder2 extends Builder { - @Override - protected Builder2 self() { - return this; + 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()); } } - public static Builder builder() { - return new Builder2(); + /** + * Creates a BatchEmitter instance using a NetworkConfiguration. + * + * @param networkConfig a NetworkConfiguration object + */ + public BatchEmitter(NetworkConfiguration networkConfig) { + this(networkConfig, new EmitterConfiguration()); } - protected BatchEmitter(Builder builder) { - super(builder); + /** + * 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); - // Precondition checks - Preconditions.checkArgument(builder.bufferSize > 0, "bufferSize must be greater than 0"); + 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)); + } - this.bufferSize = builder.bufferSize; + return result; } /** - * Adds a payload to the buffer and checks whether - * we have reached the buffer limit yet. + * 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 payload an event payload + * @param batchSize number of events to send in one request */ @Override - public synchronized void emit(TrackerPayload payload) { - buffer.add(payload); - if (buffer.size() >= bufferSize) { - flushBuffer(); + public void setBatchSize(final int batchSize) { + if (batchSize <= 0) { + throw new IllegalArgumentException("batchSize must be greater than 0"); } + this.batchSize = batchSize; } /** - * When the buffer limit is reached sending of the buffer is - * initiated. + * Gets the Emitter batchSize + * + * @return the batch size */ - public void flushBuffer() { - execute(getRequestRunnable(buffer)); - buffer = new ArrayList<>(); + @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 buffer the event buffer to be sent - * @return the new Callable object + * @param numberOfEvents the number of events to be sent in the request + * @return the new Runnable object */ - private Runnable getRequestRunnable(final List buffer) { - return new Runnable() { - @Override - public void run() { - if (buffer.size() == 0) { + 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; } - SelfDescribingJson post = getFinalPost(buffer); - int code = httpClientAdapter.post(post); + List eventsInRequest = new ArrayList<>(batchedEvents.getPayloads()); + final SelfDescribingJson post = getFinalPost(eventsInRequest); + final int code = httpClientAdapter.post(post); // Process results - int success = 0; - int failure = 0; - if (!isSuccessfulSend(code)) { - LOGGER.error("BatchEmitter failed to send {} events: code: {}", buffer.size(), code); - failure += buffer.size(); + 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.info("BatchEmitter successfully sent {} events: code: {}", buffer.size(), code); - success += buffer.size(); - } + LOGGER.error("BatchEmitter failed to send {} events: code: {}", eventsInRequest.size(), code); + eventsDeletedFromStorage = eventStore.cleanupAfterSendingAttempt(true, batchedEvents.getBatchId()); - // Send the callback if available - if (requestCallback != null) { - if (failure != 0) { - requestCallback.onFailure(success, buffer); + if (code == -1) { + callback.onFailure(FailureType.HTTP_CONNECTION_FAILURE, true, eventsInRequest); } else { - requestCallback.onSuccess(success); + 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); } } } @@ -140,28 +298,98 @@ public void run() { } /** - * Constructs the SelfDescribingJson to be sent - * to the endpoint + * Constructs the SelfDescribingJson to be sent to the endpoint * + * @param events the event buffer * @return the constructed POST payload */ - private SelfDescribingJson getFinalPost(List buffer) { - List toSendPayloads = new ArrayList<>(); - for (TrackerPayload payload : buffer) { + 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 - ); + 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); } /** - * On close attempt to send all remaining events. + * 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() { - flushBuffer(); + final long closeTimeout = 5; + isClosing = true; + + flushBuffer(); // Attempt to send all remaining events + + //Shutdown executor threadpool + if (executor != null) { + executor.shutdown(); + try { + if (!executor.awaitTermination(closeTimeout, TimeUnit.SECONDS)) { + executor.shutdownNow(); + if (!executor.awaitTermination(closeTimeout, TimeUnit.SECONDS)) + LOGGER.warn("Emitter executor did not terminate"); + } + } catch (final InterruptedException ie) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + } + + /** + * Copied from `Executors.defaultThreadFactory()`. + * The only change is the generated name prefix. + */ + static class EmitterThreadFactory implements ThreadFactory { + private static final AtomicInteger poolNumber = new AtomicInteger(1); + private final ThreadGroup group; + private final AtomicInteger threadNumber = new AtomicInteger(1); + private final String namePrefix; + + EmitterThreadFactory() { + SecurityManager securityManager = System.getSecurityManager(); + group = securityManager != null ? securityManager.getThreadGroup() : Thread.currentThread().getThreadGroup(); + namePrefix = "snowplow-emitter-pool-" + poolNumber.getAndIncrement() + "-request-thread-"; + } + + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(group, runnable, namePrefix + threadNumber.getAndIncrement(), 0L); + if (thread.isDaemon()) { + thread.setDaemon(false); + } + + if (thread.getPriority() != 5) { + thread.setPriority(5); + } + + return thread; + } } } diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchPayload.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchPayload.java new file mode 100644 index 00000000..7cc0187c --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchPayload.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.emitter; + +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +import java.util.List; + +/** + * A wrapper for a number of TrackerPayloads. + */ +public class BatchPayload { + + private final Long batchId; + private final List payloads; + + public BatchPayload(Long batchId, List payloads) { + this.batchId = batchId; + this.payloads = payloads; + } + + public Long getBatchId() { + return batchId; + } + + public List getPayloads() { + return payloads; + } + + public int size() { + return payloads.size(); + } +} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/CLAUDE.md b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/CLAUDE.md new file mode 100644 index 00000000..bbb5ad22 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/CLAUDE.md @@ -0,0 +1,350 @@ +# Emitter Module - Snowplow Java Tracker + +## Module Overview + +The emitter module handles event transmission to Snowplow collectors. It provides batching, buffering, retry logic, and asynchronous event sending with configurable HTTP clients. The BatchEmitter is the primary implementation with sophisticated error handling and exponential backoff. + +## Core Components + +``` +Emitter (interface) +└── BatchEmitter # Main implementation with batching + ├── EventStore # Event persistence interface + │ └── InMemoryEventStore # Default in-memory storage + ├── BatchPayload # POST request wrapper + ├── EmitterCallback # Success/failure callbacks + └── FailureType # Failure categorization +``` + +## Emitter Architecture Principles + +### 1. Asynchronous Processing +All events are processed asynchronously: +```java +// ✅ Events queued, not sent immediately +emitter.add(payload); // Returns quickly +// Event sent later by executor + +// ❌ Don't expect synchronous sending +emitter.add(payload); +// Event may not be sent yet! +``` + +### 2. Batch Processing Pattern +Events are batched for efficiency: +```java +// ✅ Configure batch size +EmitterConfiguration config = new EmitterConfiguration() + .batchSize(25) // Send when 25 events buffered + .bufferCapacity(1000); // Max buffer size +``` + +### 3. Retry with Exponential Backoff +Failed requests retry with increasing delays: +```java +// ✅ Automatic retry handling +// Initial: 0ms delay +// First failure: 100ms delay +// Second failure: 200ms delay +// Max delay: 600000ms (10 min) +``` + +### 4. Thread Pool Management +Configurable thread pool for sending: +```java +// ✅ Configure thread count +EmitterConfiguration config = new EmitterConfiguration() + .threadCount(2); // Number of sender threads +``` + +## BatchEmitter Implementation + +### Constructor Pattern +```java +// ✅ Use configuration objects +BatchEmitter emitter = new BatchEmitter( + networkConfig, // URL and HTTP client + emitterConfig // Batching and threading +); + +// ❌ Don't use deprecated builder +BatchEmitter.builder().url(url).build(); +``` + +### Event Addition Flow +```java +// ✅ Standard flow +boolean success = emitter.add(payload); +if (!success) { + // Buffer full, event dropped +} +``` + +### Buffer Management +```java +// ✅ Force flush buffer +emitter.flushBuffer(); + +// ✅ Close and flush +emitter.close(); // Flushes remaining events +``` + +## EventStore Pattern + +### Interface Contract +```java +public interface EventStore { + boolean add(TrackerPayload payload); + boolean remove(TrackerPayload payload); + boolean removeAll(List payloads); + List getBuffer(); + long getSize(); +} +``` + +### InMemoryEventStore Implementation +```java +// ✅ Thread-safe implementation +public class InMemoryEventStore implements EventStore { + private final AtomicLong bufferSize = new AtomicLong(0); + private final ConcurrentLinkedDeque buffer; + private final long bufferCapacity; +} +``` + +## HTTP Client Configuration + +### Client Adapter Options +```java +// ✅ OkHttp (default) +HttpClientAdapter client = new OkHttpClientAdapter(url); + +// ✅ Apache HTTP +HttpClientAdapter client = new ApacheHttpClientAdapter(url); + +// ✅ Custom implementation +HttpClientAdapter custom = new CustomAdapter(); +networkConfig.httpClientAdapter(custom); +``` + +### Cookie Management +```java +// ✅ Cookie jar for network_userid +OkHttpClientWithCookieJarAdapter adapter = + new OkHttpClientWithCookieJarAdapter(url); +``` + +## Callback Pattern + +### EmitterCallback Interface +```java +// ✅ Implement callbacks +EmitterCallback callback = new EmitterCallback() { + @Override + public void onSuccess(List payloads) { + // Handle successful send + } + + @Override + public void onFailure(FailureType type, boolean willRetry, + List payloads) { + // Handle failure + } +}; +``` + +### Failure Types +```java +public enum FailureType { + REJECTED_BY_COLLECTOR, // 4xx responses + TRACKER_ISSUE, // 5xx or network errors + EMITTER_REQUEST_FAILURE // Client-side issues +} +``` + +## Custom Retry Logic + +### Status Code Configuration +```java +// ✅ Custom retry for status codes +Map customRetry = new HashMap<>(); +customRetry.put(403, false); // Don't retry 403 +customRetry.put(500, true); // Retry 500 + +EmitterConfiguration config = new EmitterConfiguration() + .customRetryForStatusCodes(customRetry); +``` + +## Request Building + +### GET Request Pattern +```java +// ✅ Single event GET request +String url = collectorUrl + "/i?" + payload.toString(); +``` + +### POST Request Pattern +```java +// ✅ Batch POST request +BatchPayload batch = new BatchPayload(); +batch.add(payload1); +batch.add(payload2); +String json = batch.toString(); +// POST to collectorUrl + "/com.snowplowanalytics.snowplow/tp2" +``` + +## Thread Safety Patterns + +### 1. Concurrent Buffer Access +```java +// ✅ Thread-safe operations +private final ConcurrentLinkedDeque buffer; +private final AtomicLong bufferSize; +``` + +### 2. Executor Management +```java +// ✅ Proper shutdown +@Override +public void close() { + isClosing = true; + flushBuffer(); + executor.shutdown(); + executor.awaitTermination(timeout, TimeUnit.SECONDS); +} +``` + +### 3. Atomic Retry Delay +```java +// ✅ Thread-safe retry counter +private final AtomicInteger retryDelay = new AtomicInteger(0); +``` + +## Testing Emitter Behavior + +### 1. Mock EventStore +```java +// ✅ Test with mock store +EventStore mockStore = mock(EventStore.class); +when(mockStore.getBuffer()).thenReturn(payloads); +``` + +### 2. MockWebServer Testing +```java +// ✅ Test HTTP interactions +MockWebServer server = new MockWebServer(); +server.enqueue(new MockResponse().setResponseCode(200)); +BatchEmitter emitter = new BatchEmitter( + new NetworkConfiguration(server.url("/").toString()), + new EmitterConfiguration() +); +``` + +### 3. Callback Testing +```java +// ✅ Verify callbacks +AtomicBoolean success = new AtomicBoolean(false); +EmitterCallback callback = new EmitterCallback() { + @Override + public void onSuccess(List payloads) { + success.set(true); + } +}; +``` + +## Common Pitfalls + +### 1. Synchronous Expectations +```java +// ❌ Wrong: Expecting immediate send +emitter.add(payload); +assert(eventSent); // May fail + +// ✅ Correct: Wait or flush +emitter.add(payload); +emitter.flushBuffer(); +Thread.sleep(100); +``` + +### 2. Ignoring Buffer Limits +```java +// ❌ Wrong: Not checking return value +emitter.add(payload); // Might be dropped + +// ✅ Correct: Check success +if (!emitter.add(payload)) { + // Handle dropped event +} +``` + +### 3. Resource Leaks +```java +// ❌ Wrong: Not closing emitter +BatchEmitter emitter = new BatchEmitter(...); +// Use emitter... +// Never closed! + +// ✅ Correct: Always close +try (BatchEmitter emitter = new BatchEmitter(...)) { + // Use emitter +} // Auto-closed +``` + +### 4. Improper Thread Count +```java +// ❌ Wrong: Too many threads +.threadCount(100) // Excessive + +// ✅ Correct: Reasonable count +.threadCount(2) // Good default +``` + +## Performance Considerations + +### Batch Size Tuning +- Small batches (1-10): Lower latency, more requests +- Medium batches (25-50): Balanced +- Large batches (100+): Higher latency, fewer requests + +### Buffer Capacity +- Set based on expected event volume +- Consider memory constraints +- Default: 10,000 events + +### Thread Count +- 1-2 threads for most applications +- More threads for high-volume scenarios +- Consider collector capacity + +## Adding Custom Emitters + +### Template for Custom Emitter +```java +public class CustomEmitter implements Emitter { + @Override + public boolean add(TrackerPayload payload) { + // Custom logic + return true; + } + + @Override + public void flushBuffer() { + // Send all buffered events + } + + @Override + public void close() { + // Cleanup resources + } +} +``` + +## Contributing to Emitter Module + +### Guidelines +1. Maintain thread safety in all operations +2. Implement proper resource cleanup in close() +3. Honor the EmitterCallback contract +4. Test retry logic with various failure scenarios +5. Document any custom retry strategies +6. Ensure buffer limits are respected \ No newline at end of file diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/Emitter.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/Emitter.java index 0eab9e1c..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) 2015 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. @@ -12,11 +12,10 @@ */ package com.snowplowanalytics.snowplow.tracker.emitter; -// This library -import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; - import java.util.List; +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + /** * Emitter interface. */ @@ -26,40 +25,41 @@ public interface Emitter { * Adds a payload to the buffer and checks whether * we have reached the buffer limit yet. * - * @param payload an event payload + * @param payload a payload to be emitted + * @return if the payload was added to the buffer */ - void emit(TrackerPayload payload); + boolean add(TrackerPayload payload); /** - * Customize the emitter buffer size to any valid integer + * Customize the emitter batch size to any valid integer * greater than zero. - * - Will only effect the BatchEmitter * - * @param bufferSize number of events to collect before + * @param batchSize number of events to collect before * sending */ - void setBufferSize(int bufferSize); + void setBatchSize(int batchSize); /** - * When the buffer limit is reached sending of the buffer is - * initiated. - * - * This can be used to manually start sending. + * This can be used to manually send all buffered events. */ void flushBuffer(); /** - * Gets the Emitter Buffer Size - * - Will always be 1 for SimpleEmitter + * Gets the Emitter Batch Size * - * @return the buffer size + * @return the batch size */ - int getBufferSize(); + int getBatchSize(); /** * Returns the List of Payloads that are in the buffer. * - * @return the buffer payloads + * @return the buffer events */ List getBuffer(); + + /** + * Safely shuts down the Emitter. + */ + void close(); } diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/EmitterCallback.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/EmitterCallback.java new file mode 100644 index 00000000..6535b7f2 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/EmitterCallback.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker.emitter; + +import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; + +import java.util.List; + +/** + * 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 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/emitter/RequestCallback.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/RequestCallback.java deleted file mode 100644 index 00baeee4..00000000 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/RequestCallback.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (c) 2015 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; - -// Java -import java.util.List; - -// This library -import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; - -/** - * Provides a callback interface for reporting counts of successfully sent - * events and returning any failed events to be handled by the developer. - */ -public interface RequestCallback { - - /** - * If all events are sent successfully then the count - * of sent events are returned. - * - * @param successCount the successful count - */ - void onSuccess(int successCount); - - /** - * If all/some events failed then the count of successful - * events is returned along with all the failed Payloads. - * - * @param successCount the successful count - * @param failedEvents the list of failed payloads - */ - void onFailure(int successCount, List failedEvents); -} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/SimpleEmitter.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/SimpleEmitter.java deleted file mode 100644 index a86e5d82..00000000 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/emitter/SimpleEmitter.java +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright (c) 2015 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; - -// Java -import java.util.ArrayList; -import java.util.List; - -// Slf4j -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -// This library -import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; - -/** - * An emitter which sends events as soon as they are received via - * GET requests. - */ -public class SimpleEmitter extends AbstractEmitter { - - private static final Logger LOGGER = LoggerFactory.getLogger(BatchEmitter.class); - - public static abstract class Builder> extends AbstractEmitter.Builder { - public SimpleEmitter build() { - return new SimpleEmitter(this); - } - } - - private static class Builder2 extends Builder { - @Override - protected Builder2 self() { - return this; - } - } - - public static Builder builder() { - return new Builder2(); - } - - protected SimpleEmitter(Builder builder) { - super(builder); - } - - /** - * Adds a payload to the buffer and instantly sends it - * - * @param payload an event payload - */ - @Override - public void emit(TrackerPayload payload) { - execute(getRequestRunnable(payload)); - } - - /** - * When the buffer limit is reached sending of the buffer is - * initiated. - */ - public void flushBuffer() { - // Do nothing! - } - - /** - * Returns a Runnable GET Request operation - * - * @param payload the event to be sent - * @return the new Callable object - */ - private Runnable getRequestRunnable(final TrackerPayload payload) { - return new Runnable() { - @Override - public void run() { - int code = httpClientAdapter.get(payload); - - // Process results - int success = 0; - int failure = 0; - if (!isSuccessfulSend(code)) { - LOGGER.error("SimpleEmitter failed to send {} events: code: {}", 1, code); - failure += 1; - } else { - LOGGER.info("SimpleEmitter successfully sent {} events: code: {}", 1, code); - success += 1; - } - - // Send the callback if available - if (requestCallback != null) { - if (failure != 0) { - List buffer = new ArrayList<>(); - buffer.add(payload); - requestCallback.onFailure(success, buffer); - } else { - requestCallback.onSuccess(success); - } - } - } - }; - } -} diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/AbstractEvent.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/AbstractEvent.java index 6b3f9944..5731a6d5 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/AbstractEvent.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/AbstractEvent.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -16,46 +16,46 @@ import java.util.ArrayList; import java.util.LinkedList; import java.util.List; - -// Google -import com.google.common.base.Preconditions; +import java.util.Objects; // This library import com.snowplowanalytics.snowplow.tracker.Subject; -import com.snowplowanalytics.snowplow.tracker.Utils; 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 common - * elements to all events: - * - Custom Context: list of custom contexts or null - * - Timestamp: user defined event timestamp or 0 - * - Event Id: a unique id for the event - * - Subject: a unique Subject for the event + * 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; - protected long timestamp; - protected final String eventId; + + /** + * 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<>(); - private long timestamp = System.currentTimeMillis(); - private String eventId = Utils.getEventId(); + protected Long trueTimestamp = null; private Subject subject = null; protected abstract T self(); /** - * Adds a list of custom contexts. + * Adds a list of custom context entities. * - * @param context the list of contexts + * @param context the list of entities * @return itself */ public T customContext(List context) { @@ -64,30 +64,20 @@ public T customContext(List context) { } /** - * A custom event timestamp. + * The true timestamp of that event (as determined by the user). * * @param timestamp the event timestamp as * unix epoch * @return itself */ - public T timestamp(long timestamp) { - this.timestamp = timestamp; - return self(); - } - - /** - * A custom eventId for the event. - * - * @param eventId the eventId - * @return itself - */ - public T eventId(String eventId) { - this.eventId = eventId; + public T trueTimestamp(Long timestamp) { + this.trueTimestamp = timestamp; return self(); } /** - * A custom subject for the event. + * A custom Subject for the event. Its fields will override those of the + * Tracker-associated Subject, if present. * * @param subject the eventId * @return itself @@ -112,13 +102,10 @@ public static Builder builder() { protected AbstractEvent(Builder builder) { // Precondition checks - Preconditions.checkNotNull(builder.context); - Preconditions.checkNotNull(builder.eventId); - Preconditions.checkArgument(!builder.eventId.isEmpty(), "eventId cannot be empty"); + Objects.requireNonNull(builder.context); this.context = builder.context; - this.timestamp = builder.timestamp; - this.eventId = builder.eventId; + this.trueTimestamp = builder.trueTimestamp; this.subject = builder.subject; } @@ -131,19 +118,11 @@ public List getContext() { } /** - * @return the events timestamp + * @return the event's true timestamp. */ @Override - public long getTimestamp() { - return this.timestamp; - } - - /** - * @return the event id - */ - @Override - public String getEventId() { - return this.eventId; + public Long getTrueTimestamp() { + return trueTimestamp; } /** @@ -163,12 +142,13 @@ public Subject getSubject() { /** * Adds the default parameters to a TrackerPayload object. * - * @param payload the payload to add too. + * @param payload the payload to add to. * @return the TrackerPayload with appended values. */ - protected TrackerPayload putDefaultParams(TrackerPayload payload) { - payload.add(Parameter.EID, getEventId()); - payload.add(Parameter.TIMESTAMP, Long.toString(getTimestamp())); + 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 index e87f258a..f3df73d0 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/EcommerceTransaction.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/EcommerceTransaction.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -16,15 +16,31 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; - -// Google -import com.google.common.base.Preconditions; +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; @@ -52,6 +68,8 @@ public static abstract class Builder> extends AbstractEvent private List items; /** + * Required. + * * @param orderId ID of the eCommerce transaction * @return itself */ @@ -61,6 +79,8 @@ public T orderId(String orderId) { } /** + * Required. + * * @param totalValue Total transaction value * @return itself */ @@ -70,6 +90,8 @@ public T totalValue(Double totalValue) { } /** + * Optional. + * * @param affiliation Transaction affiliation * @return itself */ @@ -79,6 +101,8 @@ public T affiliation(String affiliation) { } /** + * Optional. + * * @param taxValue Transaction tax value * @return itself */ @@ -88,6 +112,8 @@ public T taxValue(Double taxValue) { } /** + * Optional. + * * @param shipping Delivery cost charged * @return itself */ @@ -97,6 +123,8 @@ public T shipping(Double shipping) { } /** + * Optional. + * * @param city Delivery address city * @return itself */ @@ -106,6 +134,8 @@ public T city(String city) { } /** + * Optional. + * * @param state Delivery address state * @return itself */ @@ -115,6 +145,8 @@ public T state(String state) { } /** + * Optional. + * * @param country Delivery address country * @return itself */ @@ -124,6 +156,8 @@ public T country(String country) { } /** + * Optional. + * * @param currency The currency the price is expressed in * @return itself */ @@ -133,6 +167,9 @@ public T currency(String currency) { } /** + * Provide a list of EcommerceTransactionItems. + * An empty list is valid, but probably not very useful. + * * @param items The items in the transaction * @return itself */ @@ -142,6 +179,9 @@ public T items(List items) { } /** + * Provide EcommerceTransactionItems directly, without explicitly adding them + * to a list beforehand. + * * @param itemArgs The items as a varargs argument * @return itself */ @@ -172,10 +212,12 @@ protected EcommerceTransaction(Builder builder) { super(builder); // Precondition checks - Preconditions.checkNotNull(builder.orderId); - Preconditions.checkNotNull(builder.totalValue); - Preconditions.checkNotNull(builder.items); - Preconditions.checkArgument(!builder.orderId.isEmpty(), "orderId cannot be empty"); + 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; @@ -190,8 +232,7 @@ protected EcommerceTransaction(Builder builder) { } /** - * Returns a TrackerPayload which can be stored into - * the local database. + * Returns a TrackerPayload which can be passed to an Emitter. * * @return the payload to be sent. */ @@ -209,11 +250,11 @@ public TrackerPayload getPayload() { payload.add(Parameter.TR_STATE, this.state); payload.add(Parameter.TR_COUNTRY, this.country); payload.add(Parameter.TR_CURRENCY, this.currency); - return putDefaultParams(payload); + return putTrueTimestamp(payload); } /** - * The list of Transaction Items passed with the event. + * The list of EcommerceTransactionItems passed with the event. * * @return the 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 index f87dc795..ca334866 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/EcommerceTransactionItem.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/EcommerceTransactionItem.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -12,14 +12,30 @@ */ package com.snowplowanalytics.snowplow.tracker.events; -// Google -import com.google.common.base.Preconditions; - // 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; @@ -41,7 +57,9 @@ public static abstract class Builder> extends AbstractEvent private String currency; /** - * @param itemId Item ID + * Required. + * + * @param itemId Item ID - ideally the same as the EcommerceTransaction orderId * @return itself */ public T itemId(String itemId) { @@ -50,6 +68,8 @@ public T itemId(String itemId) { } /** + * Required. + * * @param sku Item SKU * @return itself */ @@ -59,6 +79,8 @@ public T sku(String sku) { } /** + * Required. + * * @param price Item price * @return itself */ @@ -68,6 +90,8 @@ public T price(Double price) { } /** + * Required. + * * @param quantity Item quantity * @return itself */ @@ -77,6 +101,8 @@ public T quantity(Integer quantity) { } /** + * Optional. + * * @param name Item name * @return itself */ @@ -86,6 +112,8 @@ public T name(String name) { } /** + * Optional. + * * @param category Item category * @return itself */ @@ -95,6 +123,8 @@ public T category(String category) { } /** + * Optional. + * * @param currency The currency the price is expressed in * @return itself */ @@ -123,12 +153,16 @@ protected EcommerceTransactionItem(Builder builder) { super(builder); // Precondition checks - Preconditions.checkNotNull(builder.itemId); - Preconditions.checkNotNull(builder.sku); - Preconditions.checkNotNull(builder.price); - Preconditions.checkNotNull(builder.quantity); - Preconditions.checkArgument(!builder.itemId.isEmpty(), "itemId cannot be empty"); - Preconditions.checkArgument(!builder.sku.isEmpty(), "sku cannot be empty"); + 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; @@ -140,15 +174,7 @@ protected EcommerceTransactionItem(Builder builder) { } /** - * @param timestamp the new timestamp - */ - public void setTimestamp(long timestamp) { - this.timestamp = timestamp; - } - - /** - * Returns a TrackerPayload which can be stored into - * the local database. + * Returns a TrackerPayload which can be passed to an Emitter. * * @return the payload to be sent. */ @@ -162,6 +188,6 @@ public TrackerPayload getPayload() { 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 putDefaultParams(payload); + 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 index 12c45c42..8b66023d 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/Event.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/Event.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -12,10 +12,8 @@ */ package com.snowplowanalytics.snowplow.tracker.events; -// Java import java.util.List; -// This library import com.snowplowanalytics.snowplow.tracker.Subject; import com.snowplowanalytics.snowplow.tracker.payload.Payload; import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; @@ -26,22 +24,17 @@ public interface Event { /** - * @return the events custom context + * @return the event's custom context */ List getContext(); /** - * @return the events timestamp + * @return the event's true timestamp */ - long getTimestamp(); + Long getTrueTimestamp(); /** - * @return the event id - */ - String getEventId(); - - /** - * @return the event subject + * @return the event-associated Subject */ Subject getSubject(); diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/PageView.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/PageView.java index d167a541..52482ef8 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/PageView.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/PageView.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -12,16 +12,17 @@ */ package com.snowplowanalytics.snowplow.tracker.events; -// Google -import com.google.common.base.Preconditions; - // 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 { @@ -36,6 +37,8 @@ public static abstract class Builder> extends AbstractEvent private String referrer; /** + * Required. + * * @param pageUrl URL of the viewed page * @return itself */ @@ -45,6 +48,8 @@ public T pageUrl(String pageUrl) { } /** + * Optional. + * * @param pageTitle Title of the viewed page * @return itself */ @@ -54,7 +59,9 @@ public T pageTitle(String pageTitle) { } /** - * @param referrer Referrer of the page + * Optional. + * + * @param referrer Referrer URL of the page * @return itself */ public T referrer(String referrer) { @@ -82,8 +89,10 @@ protected PageView(Builder builder) { super(builder); // Precondition checks - Preconditions.checkNotNull(builder.pageUrl); - Preconditions.checkArgument(!builder.pageUrl.isEmpty(), "pageUrl cannot be empty"); + Objects.requireNonNull(builder.pageUrl); + if (builder.pageUrl.isEmpty()) { + throw new IllegalArgumentException("pageUrl cannot be empty"); + } this.pageUrl = builder.pageUrl; this.pageTitle = builder.pageTitle; @@ -91,8 +100,7 @@ protected PageView(Builder builder) { } /** - * Returns a TrackerPayload which can be stored into - * the local database. + * Returns a TrackerPayload which can be passed to an Emitter. * * @return the payload to be sent. */ @@ -102,6 +110,6 @@ public TrackerPayload getPayload() { payload.add(Parameter.PAGE_URL, this.pageUrl); payload.add(Parameter.PAGE_TITLE, this.pageTitle); payload.add(Parameter.PAGE_REFR, this.referrer); - return putDefaultParams(payload); + 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 index d3210edc..648cc702 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/ScreenView.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/ScreenView.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -12,15 +12,17 @@ */ package com.snowplowanalytics.snowplow.tracker.events; -// Google -import com.google.common.base.Preconditions; - -// 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.LinkedHashMap; + +/** + * Constructs a ScreenView event object. + * + * When tracked, generates a SelfDescribing event (event type "ue"). + */ public class ScreenView extends AbstractEvent { private final String name; @@ -32,7 +34,9 @@ public static abstract class Builder> extends AbstractEvent private String id; /** - * @param name The name of the screen view event + * One of name or id is required. + * + * @param name The (human-readable) name of the screen view * @return itself */ public T name(String name) { @@ -41,6 +45,8 @@ public T name(String name) { } /** + * One of name or id is required. + * * @param id Screen view ID * @return itself */ @@ -69,21 +75,24 @@ protected ScreenView(Builder builder) { super(builder); // Precondition checks - Preconditions.checkArgument(builder.name != null || builder.id != null); + 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. + * 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() { - TrackerPayload payload = new TrackerPayload(); - payload.add(Parameter.SV_ID, this.id); - payload.add(Parameter.SV_NAME, this.name); + 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/Unstructured.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/SelfDescribing.java similarity index 54% rename from src/main/java/com/snowplowanalytics/snowplow/tracker/events/Unstructured.java rename to src/main/java/com/snowplowanalytics/snowplow/tracker/events/SelfDescribing.java index 3c289575..b515b7e6 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/Unstructured.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/SelfDescribing.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -12,19 +12,23 @@ */ package com.snowplowanalytics.snowplow.tracker.events; -// Google -import com.google.common.base.Preconditions; - // 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 an Unstructured event object. + * 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 Unstructured extends AbstractEvent { +public class SelfDescribing extends AbstractEvent { private final SelfDescribingJson eventData; private boolean base64Encode; @@ -34,18 +38,19 @@ public static abstract class Builder> extends AbstractEvent private SelfDescribingJson eventData; /** - * @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 + * 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 eventData) { - this.eventData = eventData; + public T eventData(SelfDescribingJson selfDescribingJson) { + this.eventData = selfDescribingJson; return self(); } - public Unstructured build() { - return new Unstructured(this); + public SelfDescribing build() { + return new SelfDescribing(this); } } @@ -60,11 +65,11 @@ public static Builder builder() { return new Builder2(); } - protected Unstructured(Builder builder) { + protected SelfDescribing(Builder builder) { super(builder); // Precondition checks - Preconditions.checkNotNull(builder.eventData); + Objects.requireNonNull(builder.eventData); this.eventData = builder.eventData; } @@ -77,18 +82,35 @@ public void setBase64Encode(boolean base64Encode) { } /** - * Returns a TrackerPayload which can be stored into - * the local database. + * 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_UNSTRUCT_EVENT, this.eventData.getMap()); - payload.add(Parameter.EVENT, Constants.EVENT_UNSTRUCTURED); + Constants.SCHEMA_SELF_DESCRIBING_EVENT, this.eventData.getMap()); + payload.add(Parameter.EVENT, Constants.EVENT_SELF_DESCRIBING); payload.addMap(envelope.getMap(), this.base64Encode, - Parameter.UNSTRUCTURED_ENCODED, Parameter.UNSTRUCTURED); - return putDefaultParams(payload); + 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 index a0a98ee3..299eb740 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/Structured.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/Structured.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -12,16 +12,25 @@ */ package com.snowplowanalytics.snowplow.tracker.events; -// Google -import com.google.common.base.Preconditions; - // 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 { @@ -40,6 +49,8 @@ public static abstract class Builder> extends AbstractEvent private Double value; /** + * Required. + * * @param category Category of the event * @return itself */ @@ -49,7 +60,9 @@ public T category(String category) { } /** - * @param action The event itself + * Required. + * + * @param action Describes what happened in the event * @return itself */ public T action(String action) { @@ -58,7 +71,9 @@ public T action(String action) { } /** - * @param label Refer to the object the action is performed on + * Optional. + * + * @param label Refers to the object the action is performed on * @return itself */ public T label(String label) { @@ -67,6 +82,8 @@ public T label(String label) { } /** + * Optional. + * * @param property Property associated with either the action or the object * @return itself */ @@ -76,6 +93,8 @@ public T property(String property) { } /** + * Optional. + * * @param value A value associated with the user action * @return itself */ @@ -104,10 +123,14 @@ protected Structured(Builder builder) { super(builder); // Precondition checks - Preconditions.checkNotNull(builder.category); - Preconditions.checkNotNull(builder.action); - Preconditions.checkArgument(!builder.category.isEmpty(), "category cannot be empty"); - Preconditions.checkArgument(!builder.action.isEmpty(), "action cannot be empty"); + 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; @@ -117,8 +140,7 @@ protected Structured(Builder builder) { } /** - * Returns a TrackerPayload which can be stored into - * the local database. + * Returns a TrackerPayload which can be passed to an Emitter. * * @return the payload to be sent. */ @@ -131,6 +153,6 @@ public TrackerPayload getPayload() { payload.add(Parameter.SE_PROPERTY, this.property); payload.add(Parameter.SE_VALUE, this.value != null ? Double.toString(this.value) : null); - return putDefaultParams(payload); + 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 index 696b7c37..e152ff9a 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/events/Timing.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/events/Timing.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -14,15 +14,18 @@ // Java import java.util.LinkedHashMap; - -// Google -import com.google.common.base.Preconditions; +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; @@ -38,6 +41,8 @@ public static abstract class Builder> extends AbstractEvent private String label; /** + * Required. + * * @param category The category of the timed event * @return itself */ @@ -47,6 +52,8 @@ public T category(String category) { } /** + * Required. + * * @param variable Identify the timing being recorded * @return itself */ @@ -56,6 +63,8 @@ public T variable(String variable) { } /** + * Required. + * * @param timing The number of milliseconds in elapsed time to report * @return itself */ @@ -65,6 +74,8 @@ public T timing(Integer timing) { } /** + * Optional. + * * @param label Optional description of this timing * @return itself */ @@ -93,11 +104,15 @@ protected Timing(Builder builder) { super(builder); // Precondition checks - Preconditions.checkNotNull(builder.category); - Preconditions.checkNotNull(builder.timing); - Preconditions.checkNotNull(builder.variable); - Preconditions.checkArgument(!builder.category.isEmpty(), "category cannot be empty"); - Preconditions.checkArgument(!builder.variable.isEmpty(), "variable cannot be empty"); + 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; @@ -106,7 +121,8 @@ protected Timing(Builder builder) { } /** - * Return the payload wrapped into a SelfDescribingJson. + * 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. */ diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/http/AbstractHttpClientAdapter.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/AbstractHttpClientAdapter.java index e69c104b..58f586f6 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/http/AbstractHttpClientAdapter.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/AbstractHttpClientAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -12,13 +12,6 @@ */ package com.snowplowanalytics.snowplow.tracker.http; -// Java -import java.util.Map; - -// Google -import com.google.common.base.Preconditions; - -// This library import com.snowplowanalytics.snowplow.tracker.constants.Constants; import com.snowplowanalytics.snowplow.tracker.Utils; import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; @@ -31,39 +24,8 @@ public abstract class AbstractHttpClientAdapter implements HttpClientAdapter { protected final String url; - public static abstract class Builder> { - - private String url; // Required - protected abstract T self(); - - /** - * Adds a URI to the Client Adapter - * - * @param url the emitter url - * @return itself - */ - public T url(String url) { - this.url = url; - return self(); - } - } - - private static class Builder2 extends Builder { - @Override - protected Builder2 self() { - return this; - } - } - - public static Builder builder() { - return new Builder2(); - } - - protected AbstractHttpClientAdapter(Builder builder) { - // Precondition checks - Preconditions.checkArgument(Utils.isValidUrl(builder.url)); - - this.url = builder.url; + public AbstractHttpClientAdapter(String url) { + this.url = url.replaceFirst("/*$", ""); } /** @@ -94,7 +56,6 @@ public int post(SelfDescribingJson payload) { * @param payload the TrackerPayload to send */ @Override - @SuppressWarnings("unchecked") public int get(TrackerPayload payload) { String url = this.url + "/i?" + Utils.mapToQueryString(payload.getMap()); return doGet(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 index 3fa1c2c8..de72604c 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/http/ApacheHttpClientAdapter.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/ApacheHttpClientAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -12,72 +12,35 @@ */ package com.snowplowanalytics.snowplow.tracker.http; -// Java -import java.util.Map; +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; -// Google -import com.google.common.base.Preconditions; - -// Apache -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; - -// Slf4j import org.slf4j.Logger; import org.slf4j.LoggerFactory; -// This library 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 final Logger LOGGER = LoggerFactory.getLogger(ApacheHttpClientAdapter.class); + private static final Logger LOGGER = LoggerFactory.getLogger(ApacheHttpClientAdapter.class); private CloseableHttpClient httpClient; - public static abstract class Builder> extends AbstractHttpClientAdapter.Builder { - - private CloseableHttpClient httpClient; // Required - - /** - * @param httpClient The Apache HTTP Client to use - * @return itself - */ - public T httpClient(CloseableHttpClient httpClient) { - this.httpClient = httpClient; - return self(); - } - - public ApacheHttpClientAdapter build() { - return new ApacheHttpClientAdapter(this); - } - } - - private static class Builder2 extends Builder { - @Override - protected Builder2 self() { - return this; - } - } - - public static Builder builder() { - return new Builder2(); - } - - protected ApacheHttpClientAdapter(Builder builder) { - super(builder); + public ApacheHttpClientAdapter(String url, CloseableHttpClient httpClient) { + super(url); // Precondition checks - Preconditions.checkNotNull(builder.httpClient); + Objects.requireNonNull(httpClient); - this.httpClient = builder.httpClient; + this.httpClient = httpClient; } /** @@ -100,9 +63,9 @@ public Object getHttpClient() { public int doGet(String url) { try { HttpGet httpGet = new HttpGet(url); - HttpResponse httpResponse = httpClient.execute(httpGet); - httpGet.releaseConnection(); - return httpResponse.getStatusLine().getStatusCode(); + return httpClient.execute(httpGet, response -> { + return response.getCode(); + }); } catch (Exception e) { LOGGER.error("ApacheHttpClient GET Request failed: {}", e.getMessage()); return -1; @@ -121,11 +84,11 @@ 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); + StringEntity params = new StringEntity(payload, ContentType.APPLICATION_JSON); httpPost.setEntity(params); - HttpResponse httpResponse = httpClient.execute(httpPost); - httpPost.releaseConnection(); - return httpResponse.getStatusLine().getStatusCode(); + 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 index 53227bcd..105d02b7 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/http/HttpClientAdapter.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/HttpClientAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -26,6 +26,7 @@ public interface HttpClientAdapter { * single SelfDescribingJson payload * * @param payload the final event payload + * @return status code */ int post(SelfDescribingJson payload); @@ -34,6 +35,7 @@ public interface HttpClientAdapter { * GET request * * @param payload the event payload + * @return status code */ int get(TrackerPayload payload); diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/http/OkHttpClientAdapter.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/OkHttpClientAdapter.java index 91b08a7a..95df941b 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/http/OkHttpClientAdapter.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/OkHttpClientAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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,18 +13,11 @@ package com.snowplowanalytics.snowplow.tracker.http; // Java -import java.util.Iterator; -import java.util.Map; - -// Google -import com.google.common.base.Preconditions; +import java.io.IOException; +import java.util.Objects; // SquareUp -import com.squareup.okhttp.OkHttpClient; -import com.squareup.okhttp.Request; -import com.squareup.okhttp.MediaType; -import com.squareup.okhttp.Response; -import com.squareup.okhttp.RequestBody; +import okhttp3.*; // Slf4j import org.slf4j.Logger; @@ -39,46 +32,21 @@ */ public class OkHttpClientAdapter extends AbstractHttpClientAdapter { - private final Logger LOGGER = LoggerFactory.getLogger(OkHttpClientAdapter.class); - private final MediaType JSON = MediaType.parse(Constants.POST_CONTENT_TYPE); + private static final Logger LOGGER = LoggerFactory.getLogger(OkHttpClientAdapter.class); + private final MediaType JSON = MediaType.get(Constants.POST_CONTENT_TYPE); private OkHttpClient httpClient; - public static abstract class Builder> extends AbstractHttpClientAdapter.Builder { - - private OkHttpClient httpClient; // Required + public OkHttpClientAdapter(String url, OkHttpClient httpClient) { + super(url); - /** - * @param httpClient The Apache HTTP Client to use - * @return itself - */ - public T httpClient(OkHttpClient httpClient) { - this.httpClient = httpClient; - return self(); - } - - public OkHttpClientAdapter build() { - return new OkHttpClientAdapter(this); - } - } - - private static class Builder2 extends Builder { - @Override - protected Builder2 self() { - return this; - } - } + // Precondition checks + Objects.requireNonNull(httpClient); - public static Builder builder() { - return new Builder2(); + this.httpClient = httpClient; } - protected OkHttpClientAdapter(Builder builder) { - super(builder); - - // Precondition checks - Preconditions.checkNotNull(builder.httpClient); - - this.httpClient = builder.httpClient; + public OkHttpClientAdapter(String url) { + this(url, new OkHttpClient.Builder().build()); } /** @@ -96,41 +64,52 @@ public Object getHttpClient() { * GET request to the configured endpoint. * * @param url the URL send - * @return the HttpResponse for the Request + * @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(); - return response.code(); - } catch (Exception e) { + 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 -1; } + + 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 for the Request + * @return the HttpResponse code for the Request or -1 if exception is caught */ public int doPost(String url, String payload) { - try { - RequestBody body = RequestBody.create(JSON, payload); - Request request = new Request.Builder() - .url(url) - .addHeader("Content-Type", Constants.POST_CONTENT_TYPE) - .post(body) - .build(); - Response response = httpClient.newCall(request).execute(); - return response.code(); - } catch (Exception e) { + 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 -1; } + + return returnValue; } } diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/http/OkHttpClientWithCookieJarAdapter.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/OkHttpClientWithCookieJarAdapter.java new file mode 100644 index 00000000..a8d05d70 --- /dev/null +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/http/OkHttpClientWithCookieJarAdapter.java @@ -0,0 +1,29 @@ +/* + * 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. + * 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; + +// SquareUp +import okhttp3.*; + +/** + * 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 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 315ee8a0..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) 2015 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. @@ -12,7 +12,6 @@ */ package com.snowplowanalytics.snowplow.tracker.payload; -// Java import java.util.Map; /** @@ -22,9 +21,9 @@ public interface Payload { /** - * Add a key-value pair to the payload: - * - Checks that the key is not null or empty - * - Checks that the value is not null or empty + * 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 @@ -32,8 +31,8 @@ public interface Payload { void add(String key, String value); /** - * Add all the mappings from the specified map. The effect is the equivalent to that of calling: - * - add(String key, String value) for each key value pair. + * 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 */ @@ -48,14 +47,14 @@ public interface Payload { * @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); + void addMap(Map map, boolean base64Encoded, String typeEncoded, String typeNotEncoded); /** * Returns the Payload as a HashMap. * * @return A HashMap */ - Map getMap(); + Map getMap(); /** * Returns the byte size of a payload. diff --git a/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/SelfDescribingJson.java b/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/SelfDescribingJson.java index b224294e..915c17d8 100644 --- a/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/SelfDescribingJson.java +++ b/src/main/java/com/snowplowanalytics/snowplow/tracker/payload/SelfDescribingJson.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -12,34 +12,29 @@ */ package com.snowplowanalytics.snowplow.tracker.payload; -// Java import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; -// Google -import com.google.common.base.Preconditions; - -// Slf4j import org.slf4j.Logger; import org.slf4j.LoggerFactory; -// This library import com.snowplowanalytics.snowplow.tracker.Utils; import com.snowplowanalytics.snowplow.tracker.constants.Parameter; /** - * Builds a SelfDescribingJson object which can contain two fields: - * - Schema: the JsonSchema path for this Json - * - Data: the data for this Json + * 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 final Logger LOGGER = LoggerFactory.getLogger(SelfDescribingJson.class); + 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. + * String and an empty data map. Data can be added later using setData() methods. * * @param schema the schema string */ @@ -51,6 +46,11 @@ public SelfDescribingJson(String schema) { * 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 @@ -63,7 +63,7 @@ public SelfDescribingJson(String schema, TrackerPayload data) { /** * Creates a SelfDescribingJson with a Schema and a * SelfDescribingJson object. This can be used to - * nest SDJs inside of each other. + * nest SDJs inside each other. * * @param schema the schema string * @param data a SelfDescribingJson object to be embedded as @@ -90,19 +90,28 @@ public SelfDescribingJson(String schema, Object data) { * Sets the Schema for the SelfDescribingJson * * @param schema a valid schema string + * @return this SelfDescribingJson */ public SelfDescribingJson setSchema(String schema) { - Preconditions.checkNotNull(schema, "schema cannot be null"); - Preconditions.checkArgument(!schema.isEmpty(), "schema cannot be empty."); + 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 - * - Accepts a TrackerPayload object + * 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) { @@ -116,6 +125,7 @@ public SelfDescribingJson setData(TrackerPayload data) { * 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) { @@ -130,6 +140,7 @@ public SelfDescribingJson setData(Object data) { * 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) { @@ -153,7 +164,7 @@ public void addMap(Map map) { @Deprecated @Override - public void addMap(Map map, boolean base64Encoded, String typeEncoded, String typeNotEncoded) { + public void addMap(Map map, boolean base64Encoded, String typeEncoded, String typeNotEncoded) { LOGGER.info("Payload: addMap(Map, boolean, String, String) method called - Doing nothing."); } @@ -163,7 +174,7 @@ public void addMap(Map map, boolean base64Encoded, String typeEncoded, String ty * @return A Map of all the key-value entries */ @Override - public Map getMap() { + public Map getMap() { return payload; } @@ -187,4 +198,19 @@ public long getByteSize() { 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 0a48f442..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) 2015 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. @@ -12,83 +12,110 @@ */ package com.snowplowanalytics.snowplow.tracker.payload; -// Java +import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; -// Slf4j +import com.snowplowanalytics.snowplow.tracker.constants.Parameter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -// This library import com.snowplowanalytics.snowplow.tracker.Utils; /** - * Returns a standard Tracker Payload consisting of - * many key - pair values. + * 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 Logger LOGGER = LoggerFactory.getLogger(TrackerPayload.class); - private final LinkedHashMap payload = new LinkedHashMap<>(); + private static final Logger LOGGER = LoggerFactory.getLogger(TrackerPayload.class); + protected final Map payload = new LinkedHashMap<>(); + private final String eventId; + private final Long deviceCreatedTimestamp; + + + public TrackerPayload() { + eventId = Utils.getEventId(); + deviceCreatedTimestamp = System.currentTimeMillis(); + + add(Parameter.EID, eventId); + add(Parameter.DEVICE_CREATED_TIMESTAMP, Long.toString(deviceCreatedTimestamp)); + } + + public String getEventId() { + return eventId; + } + + public Long getDeviceCreatedTimestamp() { + return deviceCreatedTimestamp; + } /** - * Add a key-value pair to the payload: - * - Checks that the key is not null or empty - * - Checks that the value is not null or empty + * 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 key The parameter key * @param value The parameter value as a String */ @Override - public void add(String key, String value) { - if (key == null || key.isEmpty() || value == null || value.isEmpty()) { - LOGGER.error("Invalid kv pair detected: {}->{}", key, value); + public void add(final String key, final String value) { + if (key == null || key.isEmpty()) { + LOGGER.error("Null or empty key detected"); + return; + } + if (value == null || value.isEmpty()) { + LOGGER.debug("Null or empty value detected: {}->{}", key, value); return; } - LOGGER.info("Adding new kv pair: {}->{}", key, value); + 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, String value) for each key value pair. + * Add all the mappings from the specified map. The effect is the equivalent to + * that of calling {@link #add(String, String)} for each key value pair. * * @param map Key-Value pairs to be stored in this payload */ @Override - public void addMap(Map map) { + public void addMap(final Map map) { if (map == null) { LOGGER.debug("Map passed in is null, returning without adding map."); return; } - LOGGER.info("Adding new map: {}", map); - for (Map.Entry entry : map.entrySet()) { + 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. + * 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 map Map to be converted to a String and stored as a value + * @param base64Encoded The option you choose to encode the data + * @param typeEncoded The key that would be set if the encoding option was set to true * @param typeNotEncoded They key that would be set if the encoding option was set to false */ @Override - public void addMap(Map map, boolean base64Encoded, String typeEncoded, String typeNotEncoded) { + public void addMap(final Map map, final boolean base64Encoded, final String typeEncoded, final String typeNotEncoded) { if (map == null) { LOGGER.debug("Map passed in is null, returning nothing."); return; } - String mapString = Utils.mapToJSONString(map); - LOGGER.info("Adding new map: {}", map); + final String mapString = Utils.mapToJSONString(map); + LOGGER.debug("Adding new map: {}", map); if (base64Encoded) { - add(typeEncoded, Utils.base64Encode(mapString)); + add(typeEncoded, Utils.base64Encode(mapString, StandardCharsets.UTF_8)); } else { add(typeNotEncoded, mapString); } @@ -100,7 +127,7 @@ public void addMap(Map map, boolean base64Encoded, String typeEncoded, String ty * @return A Map of all the key-value entries */ @Override - public Map getMap() { + public Map getMap() { return payload; } @@ -115,8 +142,8 @@ public long getByteSize() { } /** - * Returns the Payload as a string. This is essentially the toString from the ObjectNode used - * to store the Payload. + * 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. */ diff --git a/src/test/java/CLAUDE.md b/src/test/java/CLAUDE.md new file mode 100644 index 00000000..341be053 --- /dev/null +++ b/src/test/java/CLAUDE.md @@ -0,0 +1,371 @@ +# Testing Guide - Snowplow Java Tracker + +## Testing Overview + +The test suite uses JUnit 4 with JUnit 5 Vintage Engine for backward compatibility. Tests focus on unit testing individual components with extensive use of mocking for external dependencies like HTTP servers. + +## Test Organization + +``` +src/test/java/com/snowplowanalytics/snowplow/tracker/ +├── TrackerTest.java # Core tracker functionality +├── SnowplowTest.java # Factory and registry +├── SubjectTest.java # Subject data management +├── UtilsTest.java # Utility functions +├── emitter/ # Emitter layer tests +├── events/ # Event type tests +├── payload/ # Payload serialization tests +└── http/ # HTTP client tests +``` + +## Core Testing Patterns + +### 1. Mock Emitter Pattern +Essential for testing tracker behavior without network calls: +```java +// ✅ Standard MockEmitter +public static class MockEmitter implements Emitter { + public List eventList = new ArrayList<>(); + + @Override + public boolean add(TrackerPayload payload) { + eventList.add(payload); + return true; + } +} +``` + +### 2. Test Setup Pattern +Consistent test initialization: +```java +// ✅ Standard setUp +@Before +public void setUp() { + mockEmitter = new MockEmitter(); + TrackerConfiguration config = new TrackerConfiguration("AF003", "cloudfront") + .base64Encoded(false); + tracker = new Tracker(config, mockEmitter); +} +``` + +### 3. MockWebServer Pattern +For testing HTTP interactions: +```java +// ✅ HTTP testing setup +MockWebServer server = new MockWebServer(); +server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("ok")); +String url = server.url("/").toString(); +``` + +### 4. Assertion Patterns +Verify event payloads correctly: +```java +// ✅ Payload verification +TrackerPayload payload = mockEmitter.eventList.get(0); +Map map = payload.getMap(); +assertEquals("pv", map.get("e")); // Event type +assertEquals(url, map.get("url")); // Page URL +assertNotNull(map.get("eid")); // Event ID +``` + +## Event Testing Patterns + +### 1. Builder Validation Tests +```java +// ✅ Test required fields +@Test(expected = NullPointerException.class) +public void testMissingRequiredField() { + PageView.builder().build(); // Missing pageUrl +} +``` + +### 2. Optional Field Tests +```java +// ✅ Test optional fields +@Test +public void testOptionalFields() { + PageView event = PageView.builder() + .pageUrl("https://example.com") + .pageTitle(null) // Should work + .build(); + assertNotNull(event); +} +``` + +### 3. Context Testing +```java +// ✅ Test custom contexts +@Test +public void testCustomContext() { + List contexts = singletonList( + new SelfDescribingJson("schema", + Collections.singletonMap("key", "value")) + ); + PageView event = PageView.builder() + .pageUrl("https://example.com") + .customContext(contexts) + .build(); +} +``` + +## Emitter Testing Patterns + +### 1. Batch Processing Tests +```java +// ✅ Test batching behavior +@Test +public void testBatchSize() throws InterruptedException { + BatchEmitter emitter = new BatchEmitter( + networkConfig, + new EmitterConfiguration().batchSize(2) + ); + emitter.add(payload1); + emitter.add(payload2); // Should trigger send + Thread.sleep(500); + // Verify batch sent +} +``` + +### 2. Retry Logic Tests +```java +// ✅ Test retry on failure +@Test +public void testRetryLogic() { + server.enqueue(new MockResponse().setResponseCode(500)); + server.enqueue(new MockResponse().setResponseCode(200)); + // Add event and verify retry +} +``` + +### 3. Callback Tests +```java +// ✅ Test callbacks +@Test +public void testSuccessCallback() { + AtomicBoolean called = new AtomicBoolean(false); + EmitterCallback callback = new EmitterCallback() { + @Override + public void onSuccess(List payloads) { + called.set(true); + } + }; + // Verify callback invoked +} +``` + +## Payload Testing Patterns + +### 1. Serialization Tests +```java +// ✅ Test JSON serialization +@Test +public void testJsonSerialization() { + SelfDescribingJson json = new SelfDescribingJson( + "schema", + Collections.singletonMap("key", "value") + ); + String result = json.toString(); + assertTrue(result.contains("\"schema\":\"schema\"")); +} +``` + +### 2. Base64 Encoding Tests +```java +// ✅ Test encoding +@Test +public void testBase64Encoding() { + TrackerConfiguration config = new TrackerConfiguration("ns", "app") + .base64Encoded(true); + // Verify contexts are base64 encoded +} +``` + +### 3. Size Calculation Tests +```java +// ✅ Test payload size +@Test +public void testPayloadSize() { + TrackerPayload payload = new TrackerPayload(); + payload.add("key", "value"); + assertTrue(payload.getByteSize() > 0); +} +``` + +## Subject Testing Patterns + +### 1. Subject Merging Tests +```java +// ✅ Test subject override +@Test +public void testSubjectOverride() { + Subject trackerSubject = new Subject(); + trackerSubject.setUserId("tracker-user"); + + Subject eventSubject = new Subject(); + eventSubject.setUserId("event-user"); + + // Event subject should override +} +``` + +### 2. Platform Detection Tests +```java +// ✅ Test platform setting +@Test +public void testPlatformDetection() { + Subject subject = new Subject(); + subject.setPlatform(DevicePlatform.Mobile); + assertEquals("mob", subject.getSubject().get("p")); +} +``` + +## Thread Safety Testing + +### 1. Concurrent Access Tests +```java +// ✅ Test thread safety +@Test +public void testConcurrentAccess() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(10); + for (int i = 0; i < 10; i++) { + new Thread(() -> { + tracker.track(event); + latch.countDown(); + }).start(); + } + latch.await(); + // Verify all events tracked +} +``` + +### 2. Buffer Overflow Tests +```java +// ✅ Test buffer limits +@Test +public void testBufferOverflow() { + EmitterConfiguration config = new EmitterConfiguration() + .bufferCapacity(2); + // Add 3 events, verify one dropped +} +``` + +## Test Utilities + +### 1. Event ID Validation +```java +// ✅ Validate UUID format +private boolean isValidUUID(String id) { + try { + UUID.fromString(id); + return true; + } catch (Exception e) { + return false; + } +} +``` + +### 2. Timestamp Validation +```java +// ✅ Validate timestamp +private boolean isValidTimestamp(String ts) { + try { + long timestamp = Long.parseLong(ts); + return timestamp > 0; + } catch (Exception e) { + return false; + } +} +``` + +### 3. JSON Validation +```java +// ✅ Validate JSON structure +private boolean isValidJson(String json) { + try { + new ObjectMapper().readTree(json); + return true; + } catch (Exception e) { + return false; + } +} +``` + +## Common Test Anti-Patterns + +### 1. Real Network Calls +```java +// ❌ Don't use real endpoints +BatchEmitter emitter = new BatchEmitter( + new NetworkConfiguration("https://real-collector.com"), + config +); + +// ✅ Use MockWebServer +MockWebServer server = new MockWebServer(); +``` + +### 2. Sleep Without Reason +```java +// ❌ Arbitrary sleep +Thread.sleep(5000); // Why? + +// ✅ Sleep with purpose +Thread.sleep(100); // Allow async operation +``` + +### 3. Missing Cleanup +```java +// ❌ Resources not cleaned +MockWebServer server = new MockWebServer(); +// Never shutdown + +// ✅ Proper cleanup +@After +public void tearDown() throws IOException { + server.shutdown(); +} +``` + +### 4. Overly Complex Mocks +```java +// ❌ Complex mock setup +when(mock.method1()).thenReturn(x); +when(mock.method2()).thenReturn(y); +// 20 more lines... + +// ✅ Simple test double +class SimpleEmitter implements Emitter { + // Minimal implementation +} +``` + +## Test Execution + +### Running Tests +```bash +# Run all tests +./gradlew test + +# Run specific test class +./gradlew test --tests TrackerTest + +# Run with coverage +./gradlew test jacocoTestReport +``` + +### Test Categories +- **Unit Tests**: Individual component testing +- **Integration Tests**: Component interaction +- **Concurrency Tests**: Thread safety verification +- **Performance Tests**: Not in main suite + +## Contributing Test Guidelines + +1. **Test Naming**: Use descriptive names (testPageViewWithAllFields) +2. **One Assertion Per Test**: Keep tests focused +3. **Mock External Dependencies**: Never make real network calls +4. **Test Edge Cases**: Null values, empty strings, limits +5. **Document Complex Tests**: Add comments for non-obvious logic +6. **Clean Up Resources**: Always close/shutdown in @After \ No newline at end of file diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/SnowplowTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/SnowplowTest.java new file mode 100644 index 00000000..b245f590 --- /dev/null +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/SnowplowTest.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2014-present Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.snowplow.tracker; + +import com.snowplowanalytics.snowplow.tracker.configuration.NetworkConfiguration; +import com.snowplowanalytics.snowplow.tracker.configuration.TrackerConfiguration; +import com.snowplowanalytics.snowplow.tracker.emitter.BatchEmitter; +import org.junit.After; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class SnowplowTest { + + @After + public void cleanUp(){ + Snowplow.reset(); + } + + @Test + public void createsAndRetrievesATracker() { + assertTrue(Snowplow.getInstancedTrackerNamespaces().isEmpty()); + + Tracker tracker = Snowplow.createTracker("namespace", "appId", "http://endpoint"); + Tracker retrievedTracker = Snowplow.getTracker("namespace"); + + assertFalse(Snowplow.getInstancedTrackerNamespaces().isEmpty()); + assertEquals(tracker, retrievedTracker); + assertEquals("namespace", tracker.getNamespace()); + assertEquals("appId", tracker.getAppId()); + assertTrue(tracker.getBase64Encoded()); + } + + @Test + public void preventsDuplicateNamespaces() { + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + Snowplow.createTracker("namespace", "appId", "http://endpoint"); + Snowplow.createTracker("namespace", "appId2", "http://collector"); + }); + + assertEquals("Tracker with this namespace already exists.", exception.getMessage()); + } + + @Test + public void deletesStoredTracker() { + Snowplow.createTracker("namespace", "appId", "http://endpoint"); + boolean result = Snowplow.removeTracker("namespace"); + assertTrue(result); + + Tracker tracker = Snowplow.createTracker("namespace2", "appId", "http://endpoint"); + boolean result2 = Snowplow.removeTracker(tracker); + assertTrue(result2); + } + + @Test + public void doesNotDeleteUnregisteredTracker() { + BatchEmitter emitter = new BatchEmitter(new NetworkConfiguration("http://collector")); + Tracker tracker = new Tracker(new TrackerConfiguration("namespace", "appId"), emitter); + + boolean result = Snowplow.removeTracker(tracker); + assertFalse(result); + + boolean result2 = Snowplow.removeTracker("not registered"); + assertFalse(result2); + } + + @Test + public void setsDefaultTrackerFromObject() { + assertNull(Snowplow.getDefaultTracker()); + + Tracker tracker = Snowplow.createTracker("namespace", "appId", "http://endpoint"); + assertEquals(tracker, Snowplow.getDefaultTracker()); + + Tracker tracker2 = Snowplow.createTracker("namespace2", "appId", "http://endpoint"); + // The first tracker is still the default + assertEquals(tracker, Snowplow.getDefaultTracker()); + + Snowplow.setDefaultTracker(tracker2); + assertEquals(tracker2, Snowplow.getDefaultTracker()); + assertEquals(2, Snowplow.getInstancedTrackerNamespaces().size()); + } + + @Test + public void setsDefaultTrackerFromNamespace() { + assertNull(Snowplow.getDefaultTracker()); + + Snowplow.createTracker("namespace", "appId", "http://endpoint"); + Tracker tracker2 = Snowplow.createTracker("namespace2", "appId", "http://endpoint"); + + boolean result = Snowplow.setDefaultTracker("namespace2"); + assertTrue(result); + assertEquals(tracker2, Snowplow.getDefaultTracker()); + } + + @Test + public void registersATrackerMadeWithoutSnowplowClass() { + BatchEmitter emitter = new BatchEmitter(new NetworkConfiguration("http://collector")); + Tracker tracker = new Tracker(new TrackerConfiguration("namespace", "appId"), emitter); + + Snowplow.registerTracker(tracker); + + assertEquals(tracker, Snowplow.getDefaultTracker()); + assertEquals(1, Snowplow.getInstancedTrackerNamespaces().size()); + } + + @Test + public void settingNewDefaultTrackerRegistersIt() { + BatchEmitter emitter = new BatchEmitter(new NetworkConfiguration("http://collector")); + Tracker tracker = new Tracker(new TrackerConfiguration("new_tracker", "appId"), emitter); + + Snowplow.setDefaultTracker(tracker); + + assertEquals(1, Snowplow.getInstancedTrackerNamespaces().size()); + assertEquals("new_tracker", Snowplow.getDefaultTracker().getNamespace()); + } + + @Test + public void createsTrackerFromConfigs() { + TrackerConfiguration trackerConfig = new TrackerConfiguration("namespace", "appId") + .base64Encoded(false) + .platform(DevicePlatform.Desktop); + NetworkConfiguration networkConfig = new NetworkConfiguration("http://collector-endpoint"); + + Tracker tracker = Snowplow.createTracker(trackerConfig, networkConfig); + Tracker retrievedTracker = Snowplow.getTracker("namespace"); + + assertFalse(Snowplow.getInstancedTrackerNamespaces().isEmpty()); + assertEquals(tracker, retrievedTracker); + assertEquals("namespace", tracker.getNamespace()); + assertEquals("appId", tracker.getAppId()); + assertEquals(DevicePlatform.Desktop, tracker.getPlatform()); + assertFalse(tracker.getBase64Encoded()); + } +} diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/SubjectTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/SubjectTest.java index ba667bfd..7af8f4b3 100644 --- a/src/test/java/com/snowplowanalytics/snowplow/tracker/SubjectTest.java +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/SubjectTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -17,84 +17,92 @@ 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 { - Subject subject = new Subject.SubjectBuilder().build(); + public void testSetUserId() { + Subject subject = new Subject(); subject.setUserId("user1"); assertEquals("user1", subject.getSubject().get("uid")); } @Test - public void testSetScreenResolution() throws Exception { - Subject subject = new Subject.SubjectBuilder().build(); + public void testSetScreenResolution() { + Subject subject = new Subject(); subject.setScreenResolution(100, 150); assertEquals("100x150", subject.getSubject().get("res")); } @Test - public void testSetViewPort() throws Exception { - Subject subject = new Subject.SubjectBuilder().build(); + public void testSetViewPort() { + Subject subject = new Subject(); subject.setViewPort(150, 100); assertEquals("150x100", subject.getSubject().get("vp")); } @Test - public void testSetColorDepth() throws Exception { - Subject subject = new Subject.SubjectBuilder().build(); + public void testSetColorDepth() { + Subject subject = new Subject(); subject.setColorDepth(10); assertEquals("10", subject.getSubject().get("cd")); } @Test - public void testSetTimezone2() throws Exception { - Subject subject = new Subject.SubjectBuilder().build(); + public void testSetTimezone2() { + Subject subject = new Subject(); subject.setTimezone("America/Toronto"); assertEquals("America/Toronto", subject.getSubject().get("tz")); } @Test - public void testSetLanguage() throws Exception { - Subject subject = new Subject.SubjectBuilder().build(); + public void testSetLanguage() { + Subject subject = new Subject(); subject.setLanguage("EN"); assertEquals("EN", subject.getSubject().get("lang")); } @Test - public void testSetIpAddress() throws Exception { - Subject subject = new Subject.SubjectBuilder().build(); + 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() throws Exception { - Subject subject = new Subject.SubjectBuilder().build(); + public void testSetUseragent() { + Subject subject = new Subject(); subject.setUseragent("useragent"); assertEquals("useragent", subject.getSubject().get("ua")); } @Test - public void testSetDuid() throws Exception { - Subject subject = new Subject.SubjectBuilder().build(); + public void testSetDomainUserId() { + Subject subject = new Subject(); subject.setDomainUserId("duid"); assertEquals("duid", subject.getSubject().get("duid")); } @Test - public void testSetNuid() throws Exception { - Subject subject = new Subject.SubjectBuilder().build(); + public void testSetNetworkUserId() { + Subject subject = new Subject(); subject.setNetworkUserId("nuid"); assertEquals("nuid", subject.getSubject().get("tnuid")); } @Test - public void testGetSubject() throws Exception { - Subject subject = new Subject.SubjectBuilder().build(); + public void testSetDomainSessionId() { + Subject subject = new Subject(); + subject.setDomainSessionId("sessionid"); + assertEquals("sessionid", subject.getSubject().get("sid")); + } + + @Test + public void testGetSubject() { + Subject subject = new Subject(); Map expected = new HashMap<>(); subject.setTimezone("America/Toronto"); subject.setUserId("user1"); @@ -104,4 +112,45 @@ public void testGetSubject() throws Exception { assertEquals(expected, subject.getSubject()); } + + @Test + public void testBuilderMethods() { + Subject subject = new Subject(); + subject + .userId("user1") + .screenResolution(100, 150) + .viewPort(150, 100) + .colorDepth(10) + .timezone("America/Toronto") + .language("EN") + .ipAddress("127.0.0.1") + .useragent("useragent") + .domainUserId("duid") + .domainSessionId("sessionid") + .networkUserId("nuid"); + assertEquals("user1", subject.getSubject().get("uid")); + assertEquals("100x150", subject.getSubject().get("res")); + assertEquals("150x100", subject.getSubject().get("vp")); + assertEquals("10", subject.getSubject().get("cd")); + assertEquals("America/Toronto", subject.getSubject().get("tz")); + assertEquals("EN", subject.getSubject().get("lang")); + assertEquals("127.0.0.1", subject.getSubject().get("ip")); + assertEquals("useragent", subject.getSubject().get("ua")); + assertEquals("duid", subject.getSubject().get("duid")); + assertEquals("sessionid", subject.getSubject().get("sid")); + assertEquals("nuid", subject.getSubject().get("tnuid")); + } + + @Test + public void testCreateFromConfig() { + SubjectConfiguration subjectConfig = new SubjectConfiguration() + .ipAddress("xxx.000.xxx.111") + .viewPort(123, 456) + .useragent("Mac OS"); + Subject subject = new Subject(subjectConfig); + + assertEquals("xxx.000.xxx.111", subject.getSubject().get("ip")); + assertEquals("123x456", subject.getSubject().get("vp")); + assertEquals("Mac OS", subject.getSubject().get("ua")); + } } diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerTest.java index 5f5fe974..c2bbbd36 100644 --- a/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerTest.java +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/TrackerTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -12,62 +12,115 @@ */ package com.snowplowanalytics.snowplow.tracker; -// Java import java.util.*; import static java.util.Collections.singletonList; -// JUnit +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 org.junit.runner.RunWith; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.*; -// Google -import com.google.common.collect.ImmutableMap; - -// Mockito -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.Mock; -import org.mockito.runners.MockitoJUnitRunner; -import static org.mockito.Mockito.*; - -// This library +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; -import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; -@RunWith(MockitoJUnitRunner.class) public class TrackerTest { public static final String EXPECTED_CONTEXTS = "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-1\",\"data\":[{\"schema\":\"schema\",\"data\":{\"foo\":\"bar\"}}]}"; - public static final String EXPECTED_EVENT_ID = "15e9b149-6029-4f6e-8447-5b9797c9e6be"; - - @Mock - Emitter emitter; - @Captor - ArgumentCaptor captor; + public static class MockEmitter implements Emitter { + public ArrayList eventList = new ArrayList<>(); + + @Override + public boolean add(TrackerPayload payload) { + eventList.add(payload); + return true; + } + @Override + public void setBatchSize(int batchSize) {} + @Override + public void flushBuffer() {} + @Override + public int getBatchSize() { return 0; } + @Override + public List getBuffer() { return null; } + @Override + public void close() {} + } + MockEmitter mockEmitter; Tracker tracker; private List contexts; @Before - public void setUp() throws Exception { - tracker = new Tracker.TrackerBuilder(emitter, "AF003", "cloudfront") - .subject(new Subject.SubjectBuilder().build()) - .base64(false) - .build(); + 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", ImmutableMap.of("foo", "bar"))); + contexts = singletonList(new SelfDescribingJson("schema", Collections.singletonMap("foo", "bar"))); } // --- Event Tests @Test - public void testEcommerceEvent() { + public void testTrackReturnsEventIdIfSuccessful() throws InterruptedException { + // a list to allow for eCommerceTransaction + List result = tracker.track(SelfDescribing.builder() + .eventData(new SelfDescribingJson( + "iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0", + Collections.singletonMap("foo", "bar") + )) + .build()); + + Thread.sleep(500); + + boolean isValidEventId = true; + try { + UUID.fromString(result.get(0)); + } catch (Exception e) { + isValidEventId = false; + } + + assertTrue(isValidEventId); + } + + @Test + public void testTrackReturnsNullIfEventWasDropped() throws InterruptedException { + class FailingMockEmitter implements Emitter { + @Override + public boolean add(TrackerPayload payload) { return false; } + @Override + public void setBatchSize(int batchSize) {} + @Override + public void flushBuffer() {} + @Override + public int getBatchSize() { return 0; } + @Override + public List getBuffer() { return null; } + @Override + public void close() {} + } + FailingMockEmitter failingMockEmitter = new FailingMockEmitter(); + tracker = new Tracker(new TrackerConfiguration("AF003", "cloudfront"), failingMockEmitter); + + List result = tracker.track(SelfDescribing.builder() + .eventData(new SelfDescribingJson( + "iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0", + Collections.singletonMap("foo", "bar") + )) + .build()); + + Thread.sleep(500); + + assertNull(result.get(0)); + } + + @Test + public void testEcommerceEvent() throws InterruptedException { // Given EcommerceTransactionItem item = EcommerceTransactionItem.builder() .itemId("order_id") @@ -78,8 +131,7 @@ public void testEcommerceEvent() { .category("category") .currency("currency") .customContext(contexts) - .timestamp(123456) - .eventId(EXPECTED_EVENT_ID) + .trueTimestamp(456789L) .build(); // When @@ -95,231 +147,351 @@ public void testEcommerceEvent() { .currency("currency") .items(item) .customContext(contexts) - .timestamp(123456) - .eventId(EXPECTED_EVENT_ID) + .trueTimestamp(456789L) + .build()); + + // Then + Thread.sleep(500); + + List results = mockEmitter.eventList; + assertEquals(2, results.size()); + + Map result1 = results.get(0).getMap(); + Map expected1 = new HashMap<>(); + expected1.put("e", "tr"); + expected1.put("tr_cu", "currency"); + expected1.put("co", EXPECTED_CONTEXTS); + expected1.put("tna", "AF003"); + expected1.put("aid", "cloudfront"); + expected1.put("tr_sh", "3.0"); + expected1.put("ttm", "456789"); + expected1.put("tz", "Etc/UTC"); + expected1.put("tr_co", "country"); + expected1.put("tv", Version.TRACKER); + expected1.put("p", "srv"); + expected1.put("tr_tx", "2.0"); + expected1.put("tr_af", "affiliation"); + expected1.put("tr_id", "order_id"); + expected1.put("tr_tt", "1.0"); + expected1.put("tr_ci", "city"); + expected1.put("tr_st", "state"); + + assertTrue(result1.entrySet().containsAll(expected1.entrySet())); + + Map result2 = results.get(1).getMap(); + Map expected2 = new HashMap<>(); + expected2.put("ti_nm", "name"); + expected2.put("ti_id", "order_id"); + expected2.put("e", "ti"); + expected2.put("co", EXPECTED_CONTEXTS); + expected2.put("tna", "AF003"); + expected2.put("aid", "cloudfront"); + expected2.put("ti_cu", "currency"); + expected2.put("ttm", "456789"); + expected2.put("tz", "Etc/UTC"); + expected2.put("ti_pr", "1.0"); + expected2.put("ti_qu", "2"); + expected2.put("p", "srv"); + expected2.put("tv", Version.TRACKER); + expected2.put("ti_ca", "category"); + expected2.put("ti_sk", "sku"); + + assertTrue(result2.entrySet().containsAll(expected2.entrySet())); + } + + @Test + public void testEcommerceTransactionItemAlone() throws InterruptedException { + // Although surprising, EcommerceTransactionItems are valid events and + // can be sent separately from EcommerceTransactions. + + tracker.track(EcommerceTransactionItem.builder() + .itemId("order_id") + .sku("sku") + .price(1.0) + .quantity(2) + .name("name") + .category("category") + .currency("currency") + .customContext(contexts) + .trueTimestamp(456789L) .build()); // Then - verify(emitter, times(2)).emit(captor.capture()); - List allValues = captor.getAllValues(); - - Map result1 = allValues.get(0).getMap(); - assertEquals(ImmutableMap.builder() - .put("e", "tr") - .put("tr_cu", "currency") - .put("co", EXPECTED_CONTEXTS) - .put("eid", EXPECTED_EVENT_ID) - .put("tna", "AF003") - .put("aid", "cloudfront") - .put("tr_sh", "3.0") - .put("dtm", "123456") - .put("tz", "Etc/UTC") - .put("tr_co", "country") - .put("tv", Version.TRACKER) - .put("p", "srv") - .put("tr_tx", "2.0") - .put("tr_af", "affiliation") - .put("tr_id", "order_id") - .put("tr_tt", "1.0") - .put("tr_ci", "city") - .put("tr_st", "state") - .build(), result1); - - Map result2 = allValues.get(1).getMap(); - assertEquals(ImmutableMap.builder() - .put("ti_nm", "name") - .put("ti_id", "order_id") - .put("e", "ti") - .put("co", EXPECTED_CONTEXTS) - .put("eid", EXPECTED_EVENT_ID) - .put("tna", "AF003") - .put("aid", "cloudfront") - .put("ti_cu", "currency") - .put("dtm", "123456") - .put("tz", "Etc/UTC") - .put("ti_pr", "1.0") - .put("ti_qu", "2") - .put("p", "srv") - .put("tv", Version.TRACKER) - .put("ti_ca", "category") - .put("ti_sk", "sku") - .build(), result2); + 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 testUnstructuredEventWithContext() { + public void testSelfDescribingEventWithContext() throws InterruptedException { // When - tracker.track(Unstructured.builder() + tracker.track(SelfDescribing.builder() .eventData(new SelfDescribingJson( - "payload", - ImmutableMap.of("foo", "bar") + "iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0", + Collections.singletonMap("foo", "bar") )) .customContext(contexts) - .timestamp(123456) - .eventId(EXPECTED_EVENT_ID) + .trueTimestamp(456789L) .build()); // Then - verify(emitter).emit(captor.capture()); - - Map result = captor.getValue().getMap(); - assertEquals(ImmutableMap.builder() - .put("p", "srv") - .put("tv", Version.TRACKER) - .put("e", "ue") - .put("co", EXPECTED_CONTEXTS) - .put("eid", EXPECTED_EVENT_ID) - .put("tna", "AF003") - .put("tz", "Etc/UTC") - .put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"payload\",\"data\":{\"foo\":\"bar\"}}}") - .put("dtm", "123456") - .put("aid", "cloudfront") - .build(), result); + 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 testUnstructuredEventWithoutContext() { + public void testSelfDescribingEventWithoutContext() throws InterruptedException { // When - tracker.track(Unstructured.builder() + tracker.track(SelfDescribing.builder() .eventData(new SelfDescribingJson( - "payload", - ImmutableMap.of("foo", "baær") + "iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0", + Collections.singletonMap("foo", "baær") )) - .timestamp(123456) - .eventId(EXPECTED_EVENT_ID) + .trueTimestamp(456789L) .build()); // Then - verify(emitter).emit(captor.capture()); - Map result = captor.getValue().getMap(); - assertEquals(ImmutableMap.builder() - .put("p", "srv") - .put("tv", Version.TRACKER) - .put("eid", EXPECTED_EVENT_ID) - .put("e", "ue") - .put("tna", "AF003") - .put("tz", "Etc/UTC") - .put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"payload\",\"data\":{\"foo\":\"baær\"}}}") - .put("dtm", "123456") - .put("aid", "cloudfront") - .build(), result); + 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 testTrackPageView() { + public void testSelfDescribingEventWithoutTrueTimestamp() throws InterruptedException { + // When + tracker.track(SelfDescribing.builder() + .eventData(new SelfDescribingJson( + "iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0", + Collections.singletonMap("foo", "bar") + )) + .build()); + + // Then + Thread.sleep(500); + + Map result = mockEmitter.eventList.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("p", "srv"); + expected.put("tv", Version.TRACKER); + expected.put("e", "ue"); + expected.put("tna", "AF003"); + expected.put("tz", "Etc/UTC"); + expected.put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0\",\"data\":{\"foo\":\"bar\"}}}"); + expected.put("aid", "cloudfront"); + + assertTrue(result.entrySet().containsAll(expected.entrySet())); + } + + @Test + public void testTrackPageView() throws InterruptedException { // When tracker.track(PageView.builder() .pageUrl("url") .pageTitle("title") .referrer("referer") .customContext(contexts) - .timestamp(123456) - .eventId(EXPECTED_EVENT_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", "pv"); + expected.put("page", "title"); + expected.put("tv", Version.TRACKER); + expected.put("p", "srv"); + expected.put("co", EXPECTED_CONTEXTS); + expected.put("tna", "AF003"); + expected.put("aid", "cloudfront"); + expected.put("refr", "referer"); + expected.put("url", "url"); + + assertTrue(result.entrySet().containsAll(expected.entrySet())); + } + + @Test + public void testTrackTwoEvents() throws InterruptedException { + // When + tracker.track(PageView.builder() + .pageUrl("url") + .pageTitle("title") + .referrer("referer") + .trueTimestamp(123456L) + .build()); + + tracker.track(SelfDescribing.builder() + .eventData(new SelfDescribingJson( + "iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0", + Collections.singletonMap("foo", "bar") + )) + .trueTimestamp(456789L) .build()); // Then - verify(emitter).emit(captor.capture()); - Map result = captor.getValue().getMap(); - assertEquals(ImmutableMap.builder() - .put("dtm", "123456") - .put("tz", "Etc/UTC") - .put("e", "pv") - .put("page", "title") - .put("tv", Version.TRACKER) - .put("p", "srv") - .put("co", EXPECTED_CONTEXTS) - .put("eid", EXPECTED_EVENT_ID) - .put("tna", "AF003") - .put("aid", "cloudfront") - .put("refr", "referer") - .put("url", "url") - .build(), result); + Thread.sleep(500); + + List results = mockEmitter.eventList; + assertEquals(2, results.size()); + + Map result1 = results.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("ttm", "123456"); + expected.put("tz", "Etc/UTC"); + expected.put("e", "pv"); + expected.put("page", "title"); + expected.put("tv", Version.TRACKER); + expected.put("p", "srv"); + expected.put("tna", "AF003"); + expected.put("aid", "cloudfront"); + expected.put("refr", "referer"); + expected.put("url", "url"); + + assertTrue(result1.entrySet().containsAll(expected.entrySet())); + + Map result2 = results.get(1).getMap(); + Map expected2 = new HashMap<>(); + expected2.put("ttm", "456789"); + expected2.put("p", "srv"); + expected2.put("tv", Version.TRACKER); + expected2.put("e", "ue"); + expected2.put("tna", "AF003"); + expected2.put("tz", "Etc/UTC"); + expected2.put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0\",\"data\":{\"foo\":\"bar\"}}}"); + expected2.put("aid", "cloudfront"); + + assertTrue(result2.entrySet().containsAll(expected2.entrySet())); } @Test - public void testTrackScreenView() { + public void testTrackScreenView() throws InterruptedException { // When tracker.track(ScreenView.builder() .name("name") .id("id") .customContext(contexts) - .timestamp(123456) - .eventId(EXPECTED_EVENT_ID) + .trueTimestamp(456789L) .build()); // Then - verify(emitter).emit(captor.capture()); - Map result = captor.getValue().getMap(); - assertEquals(ImmutableMap.builder() - .put("dtm", "123456") - .put("tz", "Etc/UTC") - .put("e", "ue") - .put("tv", Version.TRACKER) - .put("p", "srv") - .put("co", EXPECTED_CONTEXTS) - .put("eid", EXPECTED_EVENT_ID) - .put("tna", "AF003") - .put("aid", "cloudfront") - .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\"}}}") - .build(), result); + Thread.sleep(500); + + Map result = mockEmitter.eventList.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("ttm", "456789"); + expected.put("tz", "Etc/UTC"); + expected.put("e", "ue"); + expected.put("tv", Version.TRACKER); + expected.put("p", "srv"); + expected.put("co", EXPECTED_CONTEXTS); + expected.put("tna", "AF003"); + expected.put("aid", "cloudfront"); + expected.put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"iglu:com.snowplowanalytics.snowplow/screen_view/jsonschema/1-0-0\",\"data\":{\"id\":\"id\",\"name\":\"name\"}}}"); + + assertTrue(result.entrySet().containsAll(expected.entrySet())); } @Test - public void testTrackScreenViewWithTimestamp() { + public void testTrackScreenViewWithTimestamp() throws InterruptedException { // When tracker.track(ScreenView.builder() .name("name") .id("id") - .timestamp(123456) - .eventId(EXPECTED_EVENT_ID) + .trueTimestamp(456789L) .build()); // Then - verify(emitter).emit(captor.capture()); - Map result = captor.getValue().getMap(); - assertEquals(ImmutableMap.builder() - .put("dtm", "123456") - .put("tz", "Etc/UTC") - .put("e", "ue") - .put("tv", Version.TRACKER) - .put("p", "srv") - .put("eid", EXPECTED_EVENT_ID) - .put("tna", "AF003") - .put("aid", "cloudfront") - .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\"}}}") - .build(), result); + Thread.sleep(500); + + Map result = mockEmitter.eventList.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("ttm", "456789"); + expected.put("tz", "Etc/UTC"); + expected.put("e", "ue"); + expected.put("tv", Version.TRACKER); + expected.put("p", "srv"); + expected.put("tna", "AF003"); + expected.put("aid", "cloudfront"); + expected.put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"iglu:com.snowplowanalytics.snowplow/screen_view/jsonschema/1-0-0\",\"data\":{\"id\":\"id\",\"name\":\"name\"}}}"); + + assertTrue(result.entrySet().containsAll(expected.entrySet())); } @Test - public void testTrackScreenViewWithDefaultContextAndTimestamp() { + public void testTrackScreenViewWithDefaultContextAndTimestamp() throws InterruptedException { // When tracker.track(ScreenView.builder() .name("name") .id("id") .customContext(contexts) - .timestamp(123456) - .eventId(EXPECTED_EVENT_ID) + .trueTimestamp(456789L) .build()); // Then - verify(emitter).emit(captor.capture()); - Map result = captor.getValue().getMap(); - assertEquals(ImmutableMap.builder() - .put("p", "srv") - .put("tv", Version.TRACKER) - .put("e", "ue") - .put("co", EXPECTED_CONTEXTS) - .put("eid", EXPECTED_EVENT_ID) - .put("tna", "AF003") - .put("tz", "Etc/UTC") - .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\"}}}") - .put("dtm", "123456") - .put("aid", "cloudfront") - .build(), result); + Thread.sleep(500); + + Map result = mockEmitter.eventList.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("p", "srv"); + expected.put("tv", Version.TRACKER); + expected.put("e", "ue"); + expected.put("co", EXPECTED_CONTEXTS); + expected.put("tna", "AF003"); + expected.put("tz", "Etc/UTC"); + expected.put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"iglu:com.snowplowanalytics.snowplow/screen_view/jsonschema/1-0-0\",\"data\":{\"id\":\"id\",\"name\":\"name\"}}}"); + expected.put("ttm", "456789"); + expected.put("aid", "cloudfront"); + + assertTrue(result.entrySet().containsAll(expected.entrySet())); } @Test - public void testTrackTiming() { + public void testTrackTiming() throws InterruptedException { // When tracker.track(Timing.builder() .category("category") @@ -327,31 +499,31 @@ public void testTrackTiming() { .variable("variable") .timing(10) .customContext(contexts) - .timestamp(123456) - .eventId(EXPECTED_EVENT_ID) + .trueTimestamp(456789L) .build()); // Then - verify(emitter).emit(captor.capture()); - Map result = captor.getValue().getMap(); - assertEquals(ImmutableMap.builder() - .put("p", "srv") - .put("tv", Version.TRACKER) - .put("e", "ue") - .put("co", EXPECTED_CONTEXTS) - .put("eid", EXPECTED_EVENT_ID) - .put("tna", "AF003") - .put("tz", "Etc/UTC") - .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\"}}}") - .put("dtm", "123456") - .put("aid", "cloudfront") - .build(), result); + Thread.sleep(500); + + Map result = mockEmitter.eventList.get(0).getMap(); + Map expected = new HashMap<>(); + expected.put("p", "srv"); + expected.put("tv", Version.TRACKER); + expected.put("e", "ue"); + expected.put("co", EXPECTED_CONTEXTS); + expected.put("tna", "AF003"); + expected.put("tz", "Etc/UTC"); + expected.put("ue_pr", "{\"schema\":\"iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0\",\"data\":{\"schema\":\"iglu:com.snowplowanalytics.snowplow/timing/jsonschema/1-0-0\",\"data\":{\"category\":\"category\",\"label\":\"label\",\"timing\":10,\"variable\":\"variable\"}}}"); + expected.put("ttm", "456789"); + expected.put("aid", "cloudfront"); + + assertTrue(result.entrySet().containsAll(expected.entrySet())); } @Test - public void testTrackTimingWithSubject() { + public void testTrackTimingWithSubject() throws InterruptedException { // Make Subject - Subject s1 = new Subject.SubjectBuilder().build(); + Subject s1 = new Subject(); s1.setIpAddress("127.0.0.1"); s1.setTimezone("Etc/UTC"); @@ -362,86 +534,98 @@ public void testTrackTimingWithSubject() { .variable("variable") .timing(10) .customContext(contexts) - .timestamp(123456) - .eventId(EXPECTED_EVENT_ID) + .trueTimestamp(456789L) .subject(s1) .build()); // Then - verify(emitter).emit(captor.capture()); - Map result = captor.getValue().getMap(); - assertEquals(ImmutableMap.builder() - .put("p", "srv") - .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\"}}}") - .put("tv", Version.TRACKER) - .put("e", "ue") - .put("ip", "127.0.0.1") - .put("co", EXPECTED_CONTEXTS) - .put("eid", EXPECTED_EVENT_ID) - .put("tna", "AF003") - .put("tz", "Etc/UTC") - .put("dtm", "123456") - .put("aid", "cloudfront") - .build(), result); + 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 testGetTrackerVersion() throws Exception { - Tracker tracker = new Tracker.TrackerBuilder(emitter, "namespace", "an-app-id").build(); - assertEquals("java-0.8.2", tracker.getTrackerVersion()); + public void testCreateWithConfiguration() { + TrackerConfiguration trackerConfig = new TrackerConfiguration("namespace", "appId"); + trackerConfig.base64Encoded(false); + trackerConfig.platform(DevicePlatform.General); + + BatchEmitter emitter = new BatchEmitter(new NetworkConfiguration("http://collector")); + Tracker tracker = new Tracker(trackerConfig, emitter); + + assertEquals("namespace", tracker.getNamespace()); + assertEquals(emitter, tracker.getEmitter()); + } + + @Test + public void testGetTrackerVersion() { + Tracker tracker = new Tracker(new TrackerConfiguration("namespace", "an-app-id"), mockEmitter); + assertEquals("java-2.1.0", tracker.getTrackerVersion()); } @Test - public void testSetDefaultPlatform() throws Exception { - Tracker tracker = new Tracker.TrackerBuilder(emitter, "AF003", "cloudfront") - .platform(DevicePlatform.Desktop) - .build(); + public void testSetDefaultPlatform() { + TrackerConfiguration trackerConfig = new TrackerConfiguration("AF003", "cloudfront") + .platform(DevicePlatform.Desktop); + + Tracker tracker = new Tracker(trackerConfig, mockEmitter); assertEquals(DevicePlatform.Desktop, tracker.getPlatform()); - tracker.setPlatform(DevicePlatform.ConnectedTV); - assertEquals(DevicePlatform.ConnectedTV, tracker.getPlatform()); } @Test - public void testSetSubject() throws Exception { + public void testSetSubject() { + // Subject objects always have timezone set TimeZone.setDefault(TimeZone.getTimeZone("Etc/UTC")); - Subject s1 = new Subject.SubjectBuilder().build(); - Tracker tracker = new Tracker.TrackerBuilder(emitter, "AF003", "cloudfront") - .subject(s1) - .build(); - Subject s2 = new Subject.SubjectBuilder().build(); + + Subject s1 = new Subject(); + s1.setLanguage("EN"); + Tracker tracker = new Tracker(new TrackerConfiguration("AF003", "cloudfront"), mockEmitter, s1); + + Subject s2 = new Subject(); s2.setColorDepth(24); tracker.setSubject(s2); + Map subjectPairs = new HashMap<>(); subjectPairs.put("tz", "Etc/UTC"); subjectPairs.put("cd", "24"); + assertEquals(subjectPairs, tracker.getSubject().getSubject()); } @Test - public void testSetBase64Encoded() throws Exception { - Tracker tracker = new Tracker.TrackerBuilder(emitter, "AF003", "cloudfront") - .base64(false) - .build(); - assertTrue(!tracker.getBase64Encoded()); - tracker.setBase64Encoded(true); - assertTrue(tracker.getBase64Encoded()); + public void testSetBase64Encoded() { + TrackerConfiguration trackerConfig = new TrackerConfiguration("AF003", "cloudfront").base64Encoded(false); + tracker = new Tracker(trackerConfig, mockEmitter); + + assertFalse(tracker.getBase64Encoded()); } @Test - public void testSetAppId() throws Exception { - Tracker tracker = new Tracker.TrackerBuilder(emitter, "AF003", "an-app-id").build(); + public void testSetAppId() { + Tracker tracker = new Tracker(new TrackerConfiguration("AF003", "an-app-id"), mockEmitter); assertEquals("an-app-id", tracker.getAppId()); - tracker.setAppId("cloudfront"); - assertEquals("cloudfront", tracker.getAppId()); } @Test - public void testSetNamespace() throws Exception { - Tracker tracker = new Tracker.TrackerBuilder(emitter, "namespace", "an-app-id").build(); + public void testSetNamespace() { + Tracker tracker = new Tracker(new TrackerConfiguration("namespace", "an-app-id"), mockEmitter); + assertEquals("namespace", tracker.getNamespace()); - tracker.setNamespace("cloudfront"); - assertEquals("cloudfront", tracker.getNamespace()); } } diff --git a/src/test/java/com/snowplowanalytics/snowplow/tracker/UtilsTest.java b/src/test/java/com/snowplowanalytics/snowplow/tracker/UtilsTest.java index c5bd629a..24ba8e44 100644 --- a/src/test/java/com/snowplowanalytics/snowplow/tracker/UtilsTest.java +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/UtilsTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -14,21 +14,23 @@ // JUnit import org.junit.Test; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; // 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); - assertTrue(ts.length() == 13); + assertEquals(13, ts.length()); } @Test @@ -54,9 +56,28 @@ public void testIsUriValid() { assertTrue(Utils.isValidUrl(goodUri3)); String badUri1 = "www.acme.com"; - assertTrue(!Utils.isValidUrl(badUri1)); + assertFalse(Utils.isValidUrl(badUri1)); String badUri2 = "http://"; - assertTrue(!Utils.isValidUrl(badUri2)); + 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 @@ -85,9 +106,10 @@ public void testGetTimezone() { @Test public void testBase64Encode() { - String expected = "aGVsbG93b3JsZHRlc3RiNjQ="; - String b64encoded = Utils.base64Encode("helloworldtestb64"); + String expected = "aGVsbG93b3JsZHRlc3RiNjR3aXRodXRmOGNoYXJzw7TDqcOgw6c="; + String b64encoded = Utils.base64Encode("helloworldtestb64withutf8charsôéàç", StandardCharsets.UTF_8); assertEquals(expected, b64encoded); + } @Test 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 index 5b4a85f3..0eebdc6f 100644 --- a/src/test/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchEmitterTest.java +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/emitter/BatchEmitterTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -12,113 +12,598 @@ */ package com.snowplowanalytics.snowplow.tracker.emitter; -// Java -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.UUID; +import java.util.*; +import java.util.regex.Pattern; -// Google -import com.google.common.collect.Lists; - -// JUnit +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.Rule; import org.junit.Test; -import org.junit.rules.ExpectedException; - -// Mockito -import org.mockito.ArgumentCaptor; -import static org.mockito.Mockito.*; -// This library 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 HttpClientAdapter httpClientAdapter; + private MockHttpClientAdapter mockHttpClientAdapter; private BatchEmitter emitter; - @Rule - public ExpectedException expectedException = ExpectedException.none(); + 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() throws Exception { - httpClientAdapter = mock(HttpClientAdapter.class); - emitter = spy(BatchEmitter.builder() - .httpClientAdapter(httpClientAdapter) - .bufferSize(10) - .build()); + 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 - @SuppressWarnings("AssertEqualsBetweenInconvertibleTypes") - public void addToBuffer_withLess10Payloads_shouldNotFlushBuffer() throws Exception { - // Given - ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(TrackerPayload.class); + 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()); + } - // When + @Test + public void setAndGetBatchSizeWorksAsExpected() throws InterruptedException { + emitter.setBatchSize(2); + Assert.assertEquals(2, emitter.getBatchSize()); + + List payloads = createPayloads(2); for (TrackerPayload payload : payloads) { - emitter.emit(payload); + emitter.add(payload); } - // Then - verify(emitter, never()).flushBuffer(); - verify(httpClientAdapter, never()).get(argumentCaptor.capture()); + Thread.sleep(500); - Assert.assertEquals(2, emitter.getBuffer().size()); - Assert.assertEquals(payloads, emitter.getBuffer()); + Assert.assertTrue(mockHttpClientAdapter.isPostCalled); + Assert.assertEquals(0, emitter.getBuffer().size()); } @Test - @SuppressWarnings("AssertEqualsBetweenInconvertibleTypes") - public void addToBuffer_withMore10Payloads_shouldFlushBuffer() throws Exception { - // Given - ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(SelfDescribingJson.class); + 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)); + } + } - // When + @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.emit(payload); + emitter.add(payload); } + Thread.sleep(500); + + emitter.close(); Thread.sleep(500); - // Then - verify(emitter).flushBuffer(); - verify(httpClientAdapter).post(argumentCaptor.capture()); + // close() calls flushBuffer() to send all remaining stored events + Assert.assertTrue(mockHttpClientAdapter.isPostCalled); + Assert.assertEquals(0, emitter.getBuffer().size()); - List payloadMaps = new ArrayList<>(); + // 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) { - payloadMaps.add(payload.getMap()); + emitter.add(payload); } + emitter.flushBuffer(); + Thread.sleep(500); + + List storedEvents = emitter.getBuffer(); - Assert.assertEquals(payloadMaps, argumentCaptor.getValue().getMap().get("data")); - Assert.assertTrue(emitter.getBuffer().size() == 0); + Assert.assertEquals(2, storedEvents.size()); + Assert.assertTrue(storedEvents.contains(payloads.get(0))); + Assert.assertTrue(storedEvents.contains(payloads.get(1))); } @Test - public void setBufferSize_WithNegativeValue_ThrowInvalidArgumentException() throws Exception { - expectedException.expect(IllegalArgumentException.class); - emitter.setBufferSize(-1); + 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); } - private List createPayloads(int nbPayload) { - final List payloads = Lists.newArrayList(); - for (int i = 0; i < nbPayload; i++) { + @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 TrackerPayload createPayload() { - TrackerPayload payload = new TrackerPayload(); - payload.add("id", UUID.randomUUID().toString()); - return payload; + 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 index 61dfceb7..031e67ce 100644 --- a/src/test/java/com/snowplowanalytics/snowplow/tracker/http/HttpClientAdapterTest.java +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/http/HttpClientAdapterTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -12,41 +12,30 @@ */ package com.snowplowanalytics.snowplow.tracker.http; -// Java import java.io.IOException; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.concurrent.TimeUnit; -// Google -import com.google.common.collect.ImmutableMap; +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; -// SquareUp -import com.squareup.okhttp.OkHttpClient; -import com.squareup.okhttp.mockwebserver.MockResponse; -import com.squareup.okhttp.mockwebserver.MockWebServer; -import com.squareup.okhttp.mockwebserver.RecordedRequest; +import org.apache.hc.client5.http.impl.classic.HttpClients; -// Apache -import org.apache.http.impl.client.HttpClients; - -// JUnit -import org.junit.Rule; +import org.junit.Assert; import org.junit.Test; -import org.junit.rules.ExpectedException; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import static org.junit.Assert.assertEquals; -// This library import com.snowplowanalytics.snowplow.tracker.payload.SelfDescribingJson; import com.snowplowanalytics.snowplow.tracker.payload.TrackerPayload; @RunWith(Parameterized.class) public class HttpClientAdapterTest { - - @Rule - public ExpectedException expectedException = ExpectedException.none(); private final MockWebServer mockWebServer; private HttpClientAdapter adapter; @@ -61,23 +50,18 @@ public static Collection data() { {new HttpClientAdapterProvider() { @Override public HttpClientAdapter provide(String url) { - return ApacheHttpClientAdapter.builder() - .url(url) - .httpClient(HttpClients.createDefault()) - .build(); + return new ApacheHttpClientAdapter(url, HttpClients.createDefault()); } }}, {new HttpClientAdapterProvider() { @Override public HttpClientAdapter provide(String url) { - OkHttpClient httpClient = new OkHttpClient(); - httpClient.setConnectTimeout(1, TimeUnit.SECONDS); - httpClient.setReadTimeout(1, TimeUnit.SECONDS); - httpClient.setWriteTimeout(1, TimeUnit.SECONDS); - return OkHttpClientAdapter.builder() - .url(url) - .httpClient(httpClient) - .build(); + OkHttpClient httpClient = new OkHttpClient.Builder() + .connectTimeout(1, TimeUnit.SECONDS) + .readTimeout(1, TimeUnit.SECONDS) + .writeTimeout(1, TimeUnit.SECONDS) + .build(); + return new OkHttpClientAdapter(url, httpClient); } } } @@ -86,8 +70,8 @@ public HttpClientAdapter provide(String url) { public HttpClientAdapterTest(HttpClientAdapterProvider httpClientAdapterProvider) throws IOException { mockWebServer = new MockWebServer(); - mockWebServer.play(); - adapter = httpClientAdapterProvider.provide(mockWebServer.getUrl("").toString()); + mockWebServer.start(); + adapter = httpClientAdapterProvider.provide(mockWebServer.url("/").toString()); } @Test @@ -101,10 +85,15 @@ public void get_withSuccessfulStatusCode_isOk() throws Exception { 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(); - assertEquals("/i?foo=bar&space=b%20a%20r", recordedRequest.getPath()); + + String expectedString = "/i?eid=" + eventId + "&dtm=" + dtm + "&foo=bar&space=b%20a%20r"; + assertEquals(expectedString, recordedRequest.getPath()); assertEquals("GET", recordedRequest.getMethod()); } @@ -114,26 +103,63 @@ public void post_withSuccessfulStatusCode_isOk() throws InterruptedException { mockWebServer.enqueue(new MockResponse().setResponseCode(200)); // When - adapter.post(new SelfDescribingJson("schema", ImmutableMap.of("foo", "bar"))); + 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.getUtf8Body()); + 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() throws Exception { - expectedException.expect(NullPointerException.class); - adapter.post(null); + public void testPostWithNullArgument() { + Assert.assertThrows(NullPointerException.class, () -> adapter.post(null)); + } + + @Test + public void testGetWithNullArgument() { + Assert.assertThrows(NullPointerException.class, () -> adapter.get(null)); } @Test - public void testGetWithNullArgument() throws Exception { - expectedException.expect(NullPointerException.class); - adapter.get(null); + 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 index b7497160..68f144e1 100644 --- a/src/test/java/com/snowplowanalytics/snowplow/tracker/payload/SelfDescribingJsonTest.java +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/payload/SelfDescribingJsonTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -20,6 +20,9 @@ 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 { @@ -47,8 +50,12 @@ public void testMakeSdjWithObject() { 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\":{\"value\":\"key\"}}"; + + String expected = "{\"schema\":\"schema_string\",\"data\":{\"eid\":\"" + eventId + "\",\"dtm\":\"" + dtm + "\",\"value\":\"key\"}}"; String sdjString = sdj.toString(); assertNotNull(sdj); assertEquals(expected, sdjString); @@ -63,4 +70,64 @@ public void testMakeSdjWithSdj() { 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 index 144b406b..0034a648 100644 --- a/src/test/java/com/snowplowanalytics/snowplow/tracker/payload/TrackerPayloadTest.java +++ b/src/test/java/com/snowplowanalytics/snowplow/tracker/payload/TrackerPayloadTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 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. @@ -15,15 +15,42 @@ // Java import java.util.HashMap; import java.util.Map; +import java.util.UUID; // JUnit import org.junit.Test; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; + +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(); @@ -38,7 +65,7 @@ public void testAddKeyWithNullValue() { TrackerPayload payload = new TrackerPayload(); payload.add("key", null); assertNotNull(payload); - assertTrue(!payload.getMap().containsKey("key")); + assertFalse(payload.getMap().containsKey("key")); } @Test @@ -46,7 +73,7 @@ public void testAddKeyWithEmptyValue() { TrackerPayload payload = new TrackerPayload(); payload.add("key", ""); assertNotNull(payload); - assertTrue(!payload.getMap().containsKey("key")); + assertFalse(payload.getMap().containsKey("key")); } @Test @@ -67,7 +94,7 @@ public void testAddMapWithNullValue() { TrackerPayload payload = new TrackerPayload(); payload.addMap(data); assertNotNull(payload); - assertTrue(!payload.getMap().containsKey("key")); + assertFalse(payload.getMap().containsKey("key")); } @Test @@ -77,7 +104,7 @@ public void testAddMapWithEmptyValue() { TrackerPayload payload = new TrackerPayload(); payload.addMap(data); assertNotNull(payload); - assertTrue(!payload.getMap().containsKey("key")); + assertFalse(payload.getMap().containsKey("key")); } @Test 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 e7fdf41c..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 - unpack: 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 9acf93fd..00000000 --- a/vagrant/up.playbooks +++ /dev/null @@ -1,2 +0,0 @@ -oss-playbooks/java7.yml -oss-playbooks/gradle.yml