From b5837727b6036d0afc4d6daec80c77864c2d3f96 Mon Sep 17 00:00:00 2001 From: Smruti Ranjan Sahoo Date: Mon, 4 Feb 2019 14:16:47 -0800 Subject: [PATCH] first commit --- .gitignore | 17 + .travis.yml | 15 + Code-of-Conduct.md | 54 ++ Contributing.md | 39 + LICENSE | 177 +++++ README.md | 318 ++++++++ benchmark/build.gradle | 29 + .../io/ultrabrew/metrics/EmitBenchmark.java | 75 ++ .../metrics/HashFunctionBenchmark.java | 125 ++++ .../io/ultrabrew/metrics/RawHashTable.java | 157 ++++ .../ultrabrew/metrics/TagArrayBenchmark.java | 155 ++++ .../metrics/dropwizard/NoTagsBenchmark.java | 104 +++ .../metrics/dropwizard/TagsBenchmark.java | 118 +++ benchmark/src/jmh/resources/log4j2.xml | 16 + build.gradle | 159 ++++ core/build.gradle | 16 + .../java/io/ultrabrew/metrics/Counter.java | 78 ++ .../main/java/io/ultrabrew/metrics/Gauge.java | 52 ++ .../io/ultrabrew/metrics/GaugeDouble.java | 48 ++ .../metrics/JvmStatisticsCollector.java | 170 +++++ .../java/io/ultrabrew/metrics/Metric.java | 44 ++ .../io/ultrabrew/metrics/MetricRegistry.java | 149 ++++ .../java/io/ultrabrew/metrics/Reporter.java | 27 + .../main/java/io/ultrabrew/metrics/Timer.java | 77 ++ .../io/ultrabrew/metrics/data/Aggregator.java | 40 + .../metrics/data/BasicCounterAggregator.java | 62 ++ .../metrics/data/BasicGaugeAggregator.java | 70 ++ .../data/BasicGaugeDoubleAggregator.java | 73 ++ .../metrics/data/BasicTimerAggregator.java | 68 ++ .../data/ConcurrentMonoidHashTable.java | 693 ++++++++++++++++++ .../io/ultrabrew/metrics/data/Cursor.java | 32 + .../ultrabrew/metrics/data/CursorEntry.java | 85 +++ .../ultrabrew/metrics/data/MultiCursor.java | 81 ++ .../ultrabrew/metrics/data/TagSetsHelper.java | 50 ++ .../java/io/ultrabrew/metrics/data/Type.java | 21 + .../ultrabrew/metrics/data/UnsafeHelper.java | 26 + .../reporters/AggregatingReporter.java | 189 +++++ .../metrics/reporters/SLF4JReporter.java | 148 ++++ .../metrics/reporters/TimeWindowReporter.java | 157 ++++ .../io/ultrabrew/metrics/util/Intervals.java | 12 + .../io/ultrabrew/metrics/util/TagArray.java | 178 +++++ .../io/ultrabrew/metrics/CounterTest.java | 106 +++ .../io/ultrabrew/metrics/GaugeDoubleTest.java | 45 ++ .../java/io/ultrabrew/metrics/GaugeTest.java | 42 ++ .../metrics/JvmStatisticsCollectorTest.java | 64 ++ .../ultrabrew/metrics/MetricRegistryTest.java | 140 ++++ .../java/io/ultrabrew/metrics/TimerTest.java | 56 ++ .../data/BasicCounterAggregatorTest.java | 494 +++++++++++++ .../data/BasicGaugeAggregatorTest.java | 115 +++ .../data/BasicGaugeDoubleAggregatorTest.java | 154 ++++ .../data/BasicTimerAggregatorTest.java | 115 +++ .../data/ConcurrentMonoidHashTableTest.java | 27 + .../metrics/data/MultiCursorTest.java | 82 +++ .../metrics/data/TagSetsHelperTest.java | 75 ++ .../io/ultrabrew/metrics/data/TypeTest.java | 50 ++ .../metrics/data/UnsafeHelperTest.java | 43 ++ .../BasicAggregatingReporterTest.java | 83 +++ .../metrics/reporters/SLF4JReporterTest.java | 267 +++++++ .../reporters/TimeWindowReporterTest.java | 455 ++++++++++++ .../ultrabrew/metrics/util/TagArrayTest.java | 33 + core/src/test/resources/log4j2.xml | 16 + examples/undertow-httphandler/README.md | 31 + examples/undertow-httphandler/build.gradle | 17 + .../metrics/examples/ExampleServer.java | 42 ++ .../examples/handlers/HelloWorldHandler.java | 28 + .../examples/handlers/MetricsHandler.java | 61 ++ .../src/main/resources/log4j2.xml | 16 + examples/webapp/README.md | 49 ++ examples/webapp/build.gradle | 20 + .../io/ultrabrew/metrics/examples/MyApp.java | 8 + .../filters/RequestMetricsFilter.java | 44 ++ .../listeners/MetricsInitializer.java | 33 + .../examples/servlets/SlowServlet.java | 32 + examples/webapp/src/main/resources/log4j2.xml | 16 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54413 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 172 +++++ gradlew.bat | 84 +++ reporter-influxdb/build.gradle | 4 + .../reporters/influxdb/InfluxDBClient.java | 131 ++++ .../reporters/influxdb/InfluxDBReporter.java | 141 ++++ .../influxdb/InfluxDBClientTest.java | 133 ++++ .../influxdb/InfluxDBReporterTest.java | 153 ++++ reporter-opentsdb/README.md | 23 + reporter-opentsdb/build.gradle | 4 + .../opentsdb/OpenTSDBHttpClient.java | 173 +++++ .../reporters/opentsdb/OpenTSDBReporter.java | 144 ++++ .../opentsdb/OpenTSDBHttpClientTest.java | 126 ++++ .../opentsdb/OpenTSDBReporterTest.java | 221 ++++++ settings.gradle | 5 + 90 files changed, 8582 insertions(+) create mode 100755 .gitignore create mode 100755 .travis.yml create mode 100755 Code-of-Conduct.md create mode 100755 Contributing.md create mode 100755 LICENSE create mode 100755 README.md create mode 100755 benchmark/build.gradle create mode 100755 benchmark/src/jmh/java/io/ultrabrew/metrics/EmitBenchmark.java create mode 100755 benchmark/src/jmh/java/io/ultrabrew/metrics/HashFunctionBenchmark.java create mode 100755 benchmark/src/jmh/java/io/ultrabrew/metrics/RawHashTable.java create mode 100755 benchmark/src/jmh/java/io/ultrabrew/metrics/TagArrayBenchmark.java create mode 100755 benchmark/src/jmh/java/io/ultrabrew/metrics/dropwizard/NoTagsBenchmark.java create mode 100755 benchmark/src/jmh/java/io/ultrabrew/metrics/dropwizard/TagsBenchmark.java create mode 100755 benchmark/src/jmh/resources/log4j2.xml create mode 100755 build.gradle create mode 100755 core/build.gradle create mode 100755 core/src/main/java/io/ultrabrew/metrics/Counter.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/Gauge.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/GaugeDouble.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/JvmStatisticsCollector.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/Metric.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/MetricRegistry.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/Reporter.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/Timer.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/data/Aggregator.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/data/BasicCounterAggregator.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/data/BasicGaugeAggregator.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/data/BasicGaugeDoubleAggregator.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/data/BasicTimerAggregator.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/data/ConcurrentMonoidHashTable.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/data/Cursor.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/data/CursorEntry.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/data/MultiCursor.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/data/TagSetsHelper.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/data/Type.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/data/UnsafeHelper.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/reporters/AggregatingReporter.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/reporters/SLF4JReporter.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/reporters/TimeWindowReporter.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/util/Intervals.java create mode 100755 core/src/main/java/io/ultrabrew/metrics/util/TagArray.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/CounterTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/GaugeDoubleTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/GaugeTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/JvmStatisticsCollectorTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/MetricRegistryTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/TimerTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/data/BasicCounterAggregatorTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/data/BasicGaugeAggregatorTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/data/BasicGaugeDoubleAggregatorTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/data/BasicTimerAggregatorTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/data/ConcurrentMonoidHashTableTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/data/MultiCursorTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/data/TagSetsHelperTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/data/TypeTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/data/UnsafeHelperTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/reporters/BasicAggregatingReporterTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/reporters/SLF4JReporterTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/reporters/TimeWindowReporterTest.java create mode 100755 core/src/test/java/io/ultrabrew/metrics/util/TagArrayTest.java create mode 100755 core/src/test/resources/log4j2.xml create mode 100755 examples/undertow-httphandler/README.md create mode 100755 examples/undertow-httphandler/build.gradle create mode 100755 examples/undertow-httphandler/src/main/java/io/ultrabrew/metrics/examples/ExampleServer.java create mode 100755 examples/undertow-httphandler/src/main/java/io/ultrabrew/metrics/examples/handlers/HelloWorldHandler.java create mode 100755 examples/undertow-httphandler/src/main/java/io/ultrabrew/metrics/examples/handlers/MetricsHandler.java create mode 100755 examples/undertow-httphandler/src/main/resources/log4j2.xml create mode 100755 examples/webapp/README.md create mode 100755 examples/webapp/build.gradle create mode 100755 examples/webapp/src/main/java/io/ultrabrew/metrics/examples/MyApp.java create mode 100755 examples/webapp/src/main/java/io/ultrabrew/metrics/examples/filters/RequestMetricsFilter.java create mode 100755 examples/webapp/src/main/java/io/ultrabrew/metrics/examples/listeners/MetricsInitializer.java create mode 100755 examples/webapp/src/main/java/io/ultrabrew/metrics/examples/servlets/SlowServlet.java create mode 100755 examples/webapp/src/main/resources/log4j2.xml create mode 100755 gradle/wrapper/gradle-wrapper.jar create mode 100755 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100755 gradlew.bat create mode 100755 reporter-influxdb/build.gradle create mode 100755 reporter-influxdb/src/main/java/io/ultrabrew/metrics/reporters/influxdb/InfluxDBClient.java create mode 100755 reporter-influxdb/src/main/java/io/ultrabrew/metrics/reporters/influxdb/InfluxDBReporter.java create mode 100755 reporter-influxdb/src/test/java/io/ultrabrew/metrics/reporters/influxdb/InfluxDBClientTest.java create mode 100755 reporter-influxdb/src/test/java/io/ultrabrew/metrics/reporters/influxdb/InfluxDBReporterTest.java create mode 100755 reporter-opentsdb/README.md create mode 100755 reporter-opentsdb/build.gradle create mode 100755 reporter-opentsdb/src/main/java/io/ultrabrew/metrics/reporters/opentsdb/OpenTSDBHttpClient.java create mode 100755 reporter-opentsdb/src/main/java/io/ultrabrew/metrics/reporters/opentsdb/OpenTSDBReporter.java create mode 100755 reporter-opentsdb/src/test/java/io/ultrabrew/metrics/reporters/opentsdb/OpenTSDBHttpClientTest.java create mode 100755 reporter-opentsdb/src/test/java/io/ultrabrew/metrics/reporters/opentsdb/OpenTSDBReporterTest.java create mode 100755 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..d33ebf7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +target/ +build/ +*.iml +.idea/ +*~ +.DS_Store +.gradle/ +.clover/ +.docker-gradle/ + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +# vim swap files +.*.sw? + +out/ diff --git a/.travis.yml b/.travis.yml new file mode 100755 index 0000000..3986607 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: java +install: true +jdk: +- oraclejdk8 + +before_cache: +- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock +- rm -fr $HOME/.gradle/caches/*/plugin-resolution/ +cache: + directories: + - $HOME/.gradle/caches/ + - $HOME/.gradle/wrapper/ + +script: +- ./gradlew build publish \ No newline at end of file diff --git a/Code-of-Conduct.md b/Code-of-Conduct.md new file mode 100755 index 0000000..6bebafb --- /dev/null +++ b/Code-of-Conduct.md @@ -0,0 +1,54 @@ +# Oath Open Source Code of Conduct + +## Summary +This Code of Conduct is our way to encourage good behavior and discourage bad behavior in our open source community. We invite participation from many people to bring different perspectives to support this project. We pledge to do our part to foster a welcoming and professional environment free of harassment. We expect participants to communicate professionally and thoughtfully during their involvement with this project. + +Participants may lose their good standing by engaging in misconduct. For example: insulting, threatening, or conveying unwelcome sexual content. We ask participants who observe conduct issues to report the incident directly to the project's Response Team at opensource-conduct@oath.com. Oath will assign a respondent to address the issue. We may remove harassers from this project. + +This code does not replace the terms of service or acceptable use policies of the websites used to support this project. We acknowledge that participants may be subject to additional conduct terms based on their employment which may govern their online expressions. + +## Details +This Code of Conduct makes our expectations of participants in this community explicit. +* We forbid harassment and abusive speech within this community. +* We request participants to report misconduct to the project’s Response Team. +* We urge participants to refrain from using discussion forums to play out a fight. + +### Expected Behaviors +We expect participants in this community to conduct themselves professionally. Since our primary mode of communication is text on an online forum (e.g. issues, pull requests, comments, emails, or chats) devoid of vocal tone, gestures, or other context that is often vital to understanding, it is important that participants are attentive to their interaction style. + +* **Assume positive intent.** We ask community members to assume positive intent on the part of other people’s communications. We may disagree on details, but we expect all suggestions to be supportive of the community goals. +* **Respect participants.** We expect participants will occasionally disagree. Even if we reject an idea, we welcome everyone’s participation. Open Source projects are learning experiences. Ask, explore, challenge, and then respectfully assert if you agree or disagree. If your idea is rejected, be more persuasive not bitter. +* **Welcoming to new members.** New members bring new perspectives. Some may raise questions that have been addressed before. Kindly point them to existing discussions. Everyone is new to every project once. +* **Be kind to beginners.** Beginners use open source projects to get experience. They might not be talented coders yet, and projects should not accept poor quality code. But we were all beginners once, and we need to engage kindly. +* **Consider your impact on others.** Your work will be used by others, and you depend on the work of others. We expect community members to be considerate and establish a balance their self-interest with communal interest. +* **Use words carefully.** We may not understand intent when you say something ironic. Poe’s Law suggests that without an emoticon people will misinterpret sarcasm. We ask community members to communicate plainly. +* **Leave with class.** When you wish to resign from participating in this project for any reason, you are free to fork the code and create a competitive project. Open Source explicitly allows this. Your exit should not be dramatic or bitter. + +### Unacceptable Behaviors +Participants remain in good standing when they do not engage in misconduct or harassment. To elaborate: +* **Don't be a bigot.** Calling out project members by their identity or background in a negative or insulting manner. This includes, but is not limited to, slurs or insinuations related to protected or suspect classes e.g. race, color, citizenship, national origin, political belief, religion, sexual orientation, gender identity and expression, age, size, culture, ethnicity, genetic features, language, profession, national minority statue, mental or physical ability. +* **Don't insult.** Insulting remarks about a person’s lifestyle practices. +* **Don't dox.** Revealing private information about other participants without explicit permission. +* **Don't intimidate.** Threats of violence or intimidation of any project member. +* **Don't creep.** Unwanted sexual attention or content unsuited for the subject of this project. +* **Don't disrupt.** Sustained disruptions in a discussion. +* **Let us help.** Refusal to assist the Response Team to resolve an issue in the community. + +We do not list all forms of harassment, nor imply some forms of harassment are not worthy of action. Any participant who *feels* harassed or *observes* harassment, should report the incident. Victim of harassment should not address grievances in the public forum, as this often intensifies the problem. Report it, and let us address it off-line. + +### Reporting Issues +If you experience or witness misconduct, or have any other concerns about the conduct of members of this project, please report it by contacting our Response Team at opensource-conduct@oath.com who will handle your report with discretion. Your report should include: +* Your preferred contact information. We cannot process anonymous reports. +* Names (real or usernames) of those involved in the incident. +* Your account of what occurred, and if the incident is ongoing. Please provide links to or transcripts of the publicly available records (e.g. a mailing list archive or a public IRC logger), so that we can review it. +* Any additional information that may be helpful to achieve resolution. + +After filing a report, a representative will contact you directly to review the incident and ask additional questions. If a member of the Oath Response Team is named in an incident report, that member will be recused from handling your incident. If the complaint originates from a member of the Response Team, it will be addressed by a different member of the Response Team. We will consider reports to be confidential for the purpose of protecting victims of abuse. + +### Scope +Oath will assign a Response Team member with admin rights on the project and legal rights on the project copyright. The Response Team is empowered to restrict some privileges to the project as needed. Since this project is governed by an open source license, any participant may fork the code under the terms of the project license. The Response Team’s goal is to preserve the project if possible, and will restrict or remove participation from those who disrupt the project. + +This code does not replace the terms of service or acceptable use policies that are provided by the websites used to support this community. Nor does this code apply to communications or actions that take place outside of the context of this community. Many participants in this project are also subject to codes of conduct based on their employment. This code is a social-contract that informs participants of our social expectations. It is not a terms of service or legal contract. + +## License and Acknowledgment. +This text is shared under the [CC-BY-4.0 license](https://creativecommons.org/licenses/by/4.0/). This code is based on a study conducted by the [TODO Group](https://todogroup.org/) of many codes used in the open source community. If you have feedback about this code, contact our Response Team at the address listed above. \ No newline at end of file diff --git a/Contributing.md b/Contributing.md new file mode 100755 index 0000000..db8a6e5 --- /dev/null +++ b/Contributing.md @@ -0,0 +1,39 @@ +# How to contribute +First, thanks for taking the time to contribute to our project! The following information provides +a guide for making contributions. + +## Code of Conduct + +By participating in this project, you agree to abide by the [Oath Code of Conduct](Code-of-Conduct.md). +Everyone is welcome to submit a pull request or open an issue to improve the documentation, add +improvements, or report bugs. + +## How to Ask a Question + +If you simply have a question that needs an answer, [create an issue](https://help.github.com/articles/creating-an-issue/), +and label it as a question. + +## How To Contribute + +### Report a Bug or Request a Feature + +If you encounter any bugs while using this software, or want to request a new feature or +enhancement, feel free to [create an issue](https://help.github.com/articles/creating-an-issue/) to +report it, make sure you add a label to indicate what type of issue it is. + +### Contribute Code +Pull requests are welcome for bug fixes. If you want to implement something new, please +[request a feature first](#report-a-bug-or-request-a-feature) so we can discuss it. This project +uses the [Google Java Style Guide](https://google.github.io/styleguide/javaguide.html) as coding +style standard. + +#### Creating a Pull Request +Before you submit any code, we need you to agree to our +[Contributor License Agreement](https://yahoocla.herokuapp.com/); this ensures we can continue to +protect your contributions under an open source license well into the future. + +Please follow [best practices](https://github.com/trein/dev-best-practices/wiki/Git-Commit-Best-Practices) +for creating git commits. + +When your code is ready to be submitted, you can [submit a pull request](https://help.github.com/articles/creating-a-pull-request/) +to begin the code review process. diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..4947287 --- /dev/null +++ b/LICENSE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/README.md b/README.md new file mode 100755 index 0000000..07f0932 --- /dev/null +++ b/README.md @@ -0,0 +1,318 @@ +# Ultrabrew Metrics + +[![Build Status](https://travis-ci.com/ultrabrew/metrics.svg?branch=master)][Travis] + +> A lightweight, high-performance Java library to measure correctly the behavior +> of critical components in production. + +Ultrabrew Metrics is a high-performance instrumentation library designed for use +in large-scale JVM applications. It provides rich features such as metrics with +dynamic dimensions (or tags), ability to manage multiple reporters and encourages +accuracy over large deployments. + +## Table of Contents + +- [Background](#background) +- [Install](#install) +- [Usage](#usage) +- [Contribute](#contribute) +- [License](#license) + +## Background + +Existing metrics libraries such as [Dropwizard Metrics][Dropwizard] +previously served us well. Unfortunately, those libraries are starting to show +their age. As a result, we saw the need to write a new library designed +primarily for scale and to support essential features such as dynamic +dimensions. + +### Requirements + +1. Support [dynamic dimensions (tag keys and values)][TagSet] at the time of + measurement. +2. Reduce GC pressure by minimizing the number of objects created by the + library. +3. Support accurate aggregation in reporters via [monoids][Monoid], *i.e.*, + support [multiple fields][FieldSet] in a single metric to properly calculate + a mean value across multiple heterogeneous servers and/or dimensions. +4. Minimize number of dependencies. +5. Decouple instrumentation from reporting in the following ways: + 1. adding a new reporter or modifying an existing reporter does not + require changing instrumentation code; + 2. each reporter aggregates measurements independently; and + 3. multiple reporters may report at different intervals. +6. (TODO) Support raw event emission for external service consumption. + - *E.g.*, sending UDP packets to external service similar to [statsd], which + could do aggregation before sending to an actual time series storage, or + sending raw events directly to an alerting service or to a time-series + database. +7. (TODO) Support better cumulative or global-percentile approximation across + multiple servers or deployments by using structures such as + [Data Sketches][Sketches] and [T-Digests][TDigest]. + +#### Non-Functional Requirements + +1. The metrics library must allow millions of transactions per second in a + single JVM process with very little overhead. The largest known application + currently handles 4M+ RPS with 40+ threads writing to the metrics library in + a single JVM. +2. Each service transaction may cause dozens (10+) of metric measurements. +3. Each metric may have dozens (10+) of tag dimensions, each with hundreds + (100+) of tag values and a few (5+) fields. The combined time-series + cardinality in a JVM can be more than 1,000,000. + +#### Where are my Averages? + +As mentioned above, we aspire to improve accuracy of measurements at large scale. In the past, we have +used libraries that support *Average* as an aggregation function (or field) to be emitted from each +server. When looking at these metrics across a large deployment, we tend to further aggregate the metrics +leading to incorrect results (sum of averages, average of averages, etc). Most people do this without realizing +the mistake, which is very easy to make. + +In order to avoid this problem, we have taken a stance to NOT track averages and instead focus on fields that +can be further aggregated like Sum, Count, Min, Max, etc. Those who wish to obtain average values can +implement weighted-average functions at the reporting layer based on Sum and Count fields. + +For example, when tracking a latency, the library would emit: + +```java +api.request.latency.sum +api.request.latency.count +``` + +When querying the data for multiple hosts, sum all of `api.request.latency.sum` and sum all of +`api.request.latency.count`, then compute `sum (api.request.latency.sum) / sum (api.request.latency.count)`. + +#### How do we achieve high performance? + +We have heavily borrowed from practices commonly employed when building latency critical applications including +techniques often seen in [HFT Libraries][HFT]. Here are some of the ways in which we are able to squeeze out +the most performance from JVM - + +1. Avoid Synchronization by using Java Atomic classes and low-level operations from Java's Unsafe API. + Additionally, the data fields (arrays) are 64-byte aligned to match L1 and L2-cache line size to avoid + the use of locks explicitly. +2. Use primitives whenever possible to avoid high object creation and GC concerns. While this may seem obvious + we find engineers using objects excessively when primitives would suffice. +3. We have replaced Java's `HashMaps`, which tend to be object-based, with Linear Probing Tables using + primitive (`long`) arrays. +4. The core library does not create threads. Instead writes are done using the caller's thread. Reporters manage + their own threads for reading and publishing. This eliminates the need for a queue between caller and core library. + + +## Install + +In order to use the Ultrabrew Metrics library, you need to add a dependency to your Java project to +the reporters you want to use in your project. All reporters included in this repository are found +in the `bintray.com` maven repository, where the `core` project libraries are found as well. + +### Gradle + +```gradle +repositories { + maven { url 'https://dl.bintray.com/ultrabrew/m2' } +} + +dependencies { + compile group: 'io.ultrabrew.metrics', name: 'metrics-{your reporter}', version: '0.1.0' +} +``` + +### Maven + +```pom.xml + + + bintray-ultrabrew-maven + bintray + https://dl.bintray.com/ultrabrew/m2 + + + + + + io.ultrabrew.metrics + metrics-{your reporter} + 0.1.0 + + +``` + +## Usage + +There are two distinct and independent phases on using the library: Instrumentation and Reporting. +The goal is to be able to instrument the code once and only modify reporting code with no or very +minimal changes to the instrumentation. + +### Instrumentation + +#### Definitions + +##### Metric Registry + +Metric Registry is a collection of metrics, which may be subscribed by a reporter. Each metric is +always associated only with a single metric registry, but reporters may subscribe to multiple metric +registries. Generally you only need one metric registry, although you may choose to use more if you +need to organize your metrics in particular reporting groups or subscribe with different reporters. + +Note: All metrics have a unique identifier. You are not allowed to have multiple different types of +metrics for the same identifier. Furthermore, if you attach a reporter to multiple metric +registries, the reporter will aggregate all metrics with the same identifier. In general, it is best +to ensure that identifiers you use for metrics are globally unique. + +##### Metric Types + +The currently supported metric types are as follows: + +- `Counter` increment or decrement a 64-bit integer value +- `Gauge` measures a 64-bit integer value at given time +- `GaugeDouble` measure a double precision floating point value at a given time +- `Timer` measure elapsed time between two events and act as counter for these events + +Reporters are responsible for using the best aggregation mechanism, and proper monoid data fields, +based on the metric type and the monitoring or alerting system it is reporting to. This includes +possible mean, local minimum and maximum values, standard deviations, quantiles and others. + +#### Instrument Code + +An example how to create a metric registry. + +```java + MetricRegistry metricRegistry = new MetricRegistry(); +``` + +##### Counter + +An example how to use a Counter to measure a simple count with dynamic dimensions. + +```java +public class TestResource { + private static final String TAG_HOST = "host"; + private static final String TAG_CLIENT = "client"; + private final Counter errorCounter; + private final String hostName; + + public TestResource(final MetricRegistry metricRegistry, + final String hostName) { + errorCounter = metricRegistry.counter("errors"); + this.hostName = hostName; + } + + public void handleError(final String clientId) { + errorCounter.inc(TAG_CLIENT, clientId, TAG_HOST, hostName); + + // .. do something .. + } +} +``` + +##### Gauge + +An example how to use a Gauge to measure a long value at a given time. GaugeDouble works similarly, +but for double precision floating point values. + +```java +public class TestResource { + private final Gauge cacheSizeGauge; + private final String[] tagList; + private final Map cache; + + public TestResource(final MetricRegistry metricRegistry, final String hostName) { + cacheSizeGauge = metricRegistry.gauge("cacheSize"); + cache = new java.util.Map<>(); + tagList = new String[] { "host", hostName }; + } + + public void doSomething() { + cacheSizeGauge.set(cache.size(), tagList); // this example uses only static tags + } +} +``` + +##### Timer + +An example how to use a Timer to measure execution time and request count with dynamic and static +dimensions. + +```java +public class TestResource { + private static final String TAG_HOST = "host"; + private static final String TAG_CLIENT = "client"; + private static final String TAG_STATUS = "status"; + private final Timer requestTimer; + private final String hostName; + + public TestResource(final MetricRegistry metricRegistry, + final String hostName) { + requestTimer = metricRegistry.timer("requests"); + this.hostName = hostName; + } + + public void handleRequest(final String clientId) { + final long startTime = requestTimer.start(); + int statusCode; + + // .. handle request .. + + // Note: no need for separate counter for requests per sec, as count is already included + requestTimer.stop(startTime, TAG_CLIENT, clientId, TAG_HOST, hostName, TAG_STATUS, + String.valueOf(statusCode)); + } +} +``` + +### Reporting + +A reporter subscribes to a single or multiple metric registries and consumes the measurement events. +It may forward the events to an external aggregator and/or send raw events to an alerting service or +a time series database. The metrics library currently comes with the following reporters: + +* `InfluxDBReporter` reports to [InfluxDB] time series database. More information + [here](reporter-influxdb). +* `OpenTSDBReporter` reports to [OpenTSDB] time series database. More information + [here](reporter-opentsdb). +* `SLF4JReporter` reports to SLF4J Logger with given name to log the aggregated values of the + metrics. + > NOTE: This reporter **IS NOT** intended to be used in production environments, and is only + > provided for debugging purposes. + +#### SLF4JReporter + +An example how to attach a SLF4JReporter to the metric registry, and configure it to use `metrics` +SLF4J Logger. + +```java + SLF4JReporter reporter = new SLF4JReporter("metrics"); + metricRegistry.addReporter(reporter); +``` + +## Contribute + +Please refer to [the Contributing.md file](Contributing.md) for information about how to get +involved. We welcome issues, questions, and pull requests. Pull Requests are welcome. + +## Maintainers + +* Mika Mannermaa @mmannerm +* Smruti Ranjan Sahoo @smrutilal2 +* Ilpo Ruotsalainen @lonemeow +* Chris Larsen @manolama +* Arun Gupta @arungupta + +## License + +This project is licensed under the terms of the [Apache 2.0](LICENSE) open source license. Please +refer to [LICENSE](LICENSE) for the full terms. + +[DropWizard]: https://metrics.dropwizard.io +[Monoid]: https://en.wikipedia.org/wiki/Monoid +[TagSet]: https://docs.influxdata.com/influxdb/latest/write_protocols/line_protocol_tutorial/#tag-set +[FieldSet]: https://docs.influxdata.com/influxdb/v1.2/write_protocols/line_protocol_tutorial/#field-set +[statsd]: https://github.com/etsy/statsd +[InfluxDB]: https://www.influxdata.com/time-series-platform/influxdb/ +[OpenTSDB]: http://opentsdb.net +[Sketches]: https://datasketches.github.io/ +[TDigest]: https://github.com/tdunning/t-digest +[HFT]: https://github.com/OpenHFT +[BuildBanner]: https://travis-ci.com/ultrabrew/metrics.svg?branch=master +[Travis]: https://travis-ci.com/ultrabrew/metrics \ No newline at end of file diff --git a/benchmark/build.gradle b/benchmark/build.gradle new file mode 100755 index 0000000..fe00d05 --- /dev/null +++ b/benchmark/build.gradle @@ -0,0 +1,29 @@ +apply plugin: 'me.champeau.gradle.jmh' + +publishMavenJavaPublicationToMavenRepository { + enabled = false +} + +dependencies { + jmh project(':core') + + jmh group: 'org.openjdk.jmh', name: 'jmh-core', version: '1.21' + jmh group: 'org.openjdk.jmh', name: 'jmh-generator-annprocess', version: '1.21' + jmh group: 'io.dropwizard.metrics', name: 'metrics-core', version: '3.2.2' + jmh group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.11.1' + jmh group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.11.1' +} + +jmh { + includeTests = false + fork = 1 + zip64 = true + duplicateClassesStrategy = 'warn' + //profilers = ['hs_gc'] +} + +// XXX Is this still needed if we upgrade jmh plugin to latest? +task deleteEmptyBmList(type: Delete) { + delete "${buildDir}/jmh-generated-classes/META-INF/BenchmarkList" +} +jmhCompileGeneratedClasses.finalizedBy deleteEmptyBmList diff --git a/benchmark/src/jmh/java/io/ultrabrew/metrics/EmitBenchmark.java b/benchmark/src/jmh/java/io/ultrabrew/metrics/EmitBenchmark.java new file mode 100755 index 0000000..aa382a2 --- /dev/null +++ b/benchmark/src/jmh/java/io/ultrabrew/metrics/EmitBenchmark.java @@ -0,0 +1,75 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.infra.Blackhole; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class EmitBenchmark { + + private static final String ENDPOINT = "endpoint"; + private static final String CLIENT = "client_id"; + private static final String STATUS = "status_code"; + + @State(Scope.Benchmark) + public static class BenchmarkState { + + volatile long foo = 0; + } + + @Benchmark + public void testEmitHashMap(final Blackhole bh, BenchmarkState state) { + emit(bh, "measurement1", new java.util.HashMap() {{ + put(ENDPOINT, "/api/v1/ping"); + put(CLIENT, "domain.testservice"); + put(STATUS, String.valueOf(state.foo++)); + }}, 1L); + } + + @Benchmark + public void testEmitArray(final Blackhole bh, BenchmarkState state) { + emit(bh, "measurement1", new String[]{ + ENDPOINT, "/api/v1/ping", + CLIENT, "domain.testservice", + STATUS, String.valueOf(state.foo++)}, + 1L); + } + + @Benchmark + public void testEmitVarargs(final Blackhole bh, BenchmarkState state) { + emit(bh, "measurement1", 1L, + ENDPOINT, "/api/v1/ping", + CLIENT, "domain.testservice", + STATUS, String.valueOf(state.foo++)); + } + + private void emit(final Blackhole bh, final String name, final String[] tags, final long value) { + bh.consume(name); + bh.consume(tags); + bh.consume(value); + } + + private void emit(final Blackhole bh, final String name, final Map tags, + final long value) { + bh.consume(name); + bh.consume(tags); + bh.consume(value); + } + + private void emit(final Blackhole bh, final String name, final long value, String... tags) { + bh.consume(name); + bh.consume(value); + bh.consume(tags); + } +} diff --git a/benchmark/src/jmh/java/io/ultrabrew/metrics/HashFunctionBenchmark.java b/benchmark/src/jmh/java/io/ultrabrew/metrics/HashFunctionBenchmark.java new file mode 100755 index 0000000..dcc12da --- /dev/null +++ b/benchmark/src/jmh/java/io/ultrabrew/metrics/HashFunctionBenchmark.java @@ -0,0 +1,125 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +import java.math.BigInteger; +import java.security.SecureRandom; +import java.util.concurrent.ThreadLocalRandom; +import org.openjdk.jmh.annotations.AuxCounters; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +@Threads(1) +@Fork(1) +@Warmup(iterations = 1) +@Measurement(iterations = 1) +@State(Scope.Benchmark) +public class HashFunctionBenchmark { + + private static final SecureRandom secureRandom = new SecureRandom(); + + private RawHashTable primeHashTable; + private RawHashTable nonPrimeHashTable; + private Input[] inputs; + + @Param({"16384"}) + private int inputSize; + + /* + @Param({ + "210", "222", "226", "228", "232", "238", "240", "250", "256", "262", "268", "270", "276", "280", "282", "292", + "306", "310", "312", "316", "330", "336", "346", "348", "352", "358", "366", "372", "378", "382", "388", "396", + "400", "408", "418", "420", "430", "432", "438", "442", "448", "456", "460", "462", "466", "478", "488", "490", + "498", "502", "508", "520", "522", "540", "546", "556", "562", "568", "570", "576", "586", "592", "598", "600", + "606", "612", "616", "618", "630", "640", "642", "646", "652", "658", "660", "672", "676", "682", "690", "700" + })*/ + @Param({"210"}) + private int capacity; + + private static class Input { + + private String[] tags; + private long value; + } + + @State(Scope.Thread) + @AuxCounters(AuxCounters.Type.OPERATIONS) + public static class OpCounters { + + public int put; + public int scans; + public double filled; + } + + @State(Scope.Thread) + @AuxCounters(AuxCounters.Type.EVENTS) + public static class EventCounters { + + public int capacity; + } + + @Setup + public void setup() { + primeHashTable = new RawHashTable( + BigInteger.valueOf(capacity).nextProbablePrime().intValueExact()); + nonPrimeHashTable = new RawHashTable(capacity); + inputs = new Input[inputSize]; + for (int i = 0; i < inputs.length; i++) { + inputs[i] = generateInput(0L, 100L, 0, 2, 100); + } + } + + private Input generateInput(final long minValue, final long maxValue, + final int minTags, final int maxTags, + final int tagCardinality) { + Input input = new Input(); + + if (minValue == maxValue) { + input.value = minValue; + } else { + input.value = ThreadLocalRandom.current().nextLong(maxValue - minValue) + minValue; + } + + int tags = secureRandom.nextInt(maxTags - minTags) + minTags; + if (tags > 0) { + input.tags = new String[tags * 2]; + for (int i = 0; i < tags; i++) { + input.tags[i] = "tag" + i; + input.tags[i + 1] = String.valueOf(secureRandom.nextInt(tagCardinality)); + } + } + + return input; + } + + @Benchmark + public void primeCapacity(OpCounters opCounters, EventCounters eventCounters) { + for (final Input i : inputs) { + primeHashTable.put(i.tags, i.value); + opCounters.put++; + opCounters.scans += primeHashTable.lastScanLength(); + } + opCounters.filled = (double) primeHashTable.size() / primeHashTable.capacity(); + eventCounters.capacity = primeHashTable.capacity(); + } + + @Benchmark + public void nonPrimeCapacity(OpCounters opCounters, EventCounters eventCounters) { + for (final Input i : inputs) { + nonPrimeHashTable.put(i.tags, i.value); + opCounters.put++; + opCounters.scans += nonPrimeHashTable.lastScanLength(); + } + opCounters.filled = (double) nonPrimeHashTable.size() / nonPrimeHashTable.capacity(); + eventCounters.capacity = nonPrimeHashTable.capacity(); + } +} diff --git a/benchmark/src/jmh/java/io/ultrabrew/metrics/RawHashTable.java b/benchmark/src/jmh/java/io/ultrabrew/metrics/RawHashTable.java new file mode 100755 index 0000000..79077e5 --- /dev/null +++ b/benchmark/src/jmh/java/io/ultrabrew/metrics/RawHashTable.java @@ -0,0 +1,157 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +import io.ultrabrew.metrics.data.UnsafeHelper; +import java.util.Arrays; +import sun.misc.Unsafe; + +public class RawHashTable { + + private static final Unsafe unsafe = UnsafeHelper.unsafe; + private static final int RECORD_SIZE = 8; // align to 64-byte cache line + private static final long usedOffset; + private static final long scanLengthOffset; + + static { + try { + usedOffset = unsafe.objectFieldOffset(RawHashTable.class.getDeclaredField("used")); + scanLengthOffset = unsafe + .objectFieldOffset(RawHashTable.class.getDeclaredField("scanLength")); + } catch (NoSuchFieldException e) { + throw new Error(e); + } + } + + private final long[] table; + private final int capacity; + private int used = 0; + private int scanLength = 0; + + public RawHashTable(final int capacity) { + this.capacity = capacity; + table = new long[this.capacity * RECORD_SIZE]; + } + + public void put(final String[] tags, final long value) { + final long hashCode = Arrays.hashCode(tags); + final int i = index(hashCode); + final long base = Unsafe.ARRAY_LONG_BASE_OFFSET + i * Unsafe.ARRAY_LONG_INDEX_SCALE; + + unsafe.getAndAddLong(table, base + Unsafe.ARRAY_LONG_INDEX_SCALE, 1L); + unsafe.getAndAddLong(table, base + 2 * Unsafe.ARRAY_LONG_INDEX_SCALE, value); + min(base + 3 * Unsafe.ARRAY_LONG_INDEX_SCALE, value); + max(base + 4 * Unsafe.ARRAY_LONG_INDEX_SCALE, value); + } + + public long getCount(final String[] tags) { + return getLong(tags, 1); + } + + public long getSum(final String[] tags) { + return getLong(tags, 2); + } + + public long getMin(final String[] tags) { + return getLong(tags, 3); + } + + public long getMax(final String[] tags) { + return getLong(tags, 4); + } + + public int size() { + return unsafe.getIntVolatile(this, usedOffset); + } + + public int capacity() { + return capacity; + } + + public int lastScanLength() { + return unsafe.getIntVolatile(this, scanLengthOffset); + } + + private long getLong(final String[] tags, int offset) { + final long hashCode = Arrays.hashCode(tags); + final int i = index(hashCode); + final long base = Unsafe.ARRAY_LONG_BASE_OFFSET + i * Unsafe.ARRAY_LONG_INDEX_SCALE; + + return unsafe.getLongVolatile(table, base + offset * Unsafe.ARRAY_LONG_INDEX_SCALE); + } + + private void min(final long offset, final long value) { + long old; + do { + old = unsafe.getLong(table, offset); + if (value >= old) { + return; + } + ///CLOVER:OFF + // No reliable way to test without being able to mock unsafe + } while (!unsafe.compareAndSwapLong(table, offset, old, value)); + ///CLOVER:ON + } + + private void max(final long offset, final long value) { + long old; + do { + old = unsafe.getLong(table, offset); + if (value <= old) { + return; + } + ///CLOVER:OFF + // No reliable way to test without being able to mock unsafe + } while (!unsafe.compareAndSwapLong(table, offset, old, value)); + ///CLOVER:ON + } + + int index(final long key) { + int start = (Math.abs((int) key) % capacity); + int i = start < 0 ? 0 : start * RECORD_SIZE; + boolean failSafe = false; + + for (int counter = 1; ; counter++) { + + final long offset = Unsafe.ARRAY_LONG_BASE_OFFSET + i * Unsafe.ARRAY_LONG_INDEX_SCALE; + + // check if we found our key + final long candidate = unsafe.getLongVolatile(table, offset); + if (key == candidate) { + unsafe.putIntVolatile(this, scanLengthOffset, counter); + return i; + } + + // check if we found empty slot + if (0L == candidate) { + // try to reserve it + + ///CLOVER:OFF + // No reliable way to test without being able to mock unsafe + if (unsafe.compareAndSwapLong(table, offset, 0L, key)) { + ///CLOVER:ON + + final int localUsed = unsafe.getAndAddInt(this, usedOffset, 1) + 1; + unsafe.putLongVolatile(table, offset + 3 * Unsafe.ARRAY_LONG_INDEX_SCALE, Long.MAX_VALUE); + unsafe.putLongVolatile(table, offset + 4 * Unsafe.ARRAY_LONG_INDEX_SCALE, Long.MIN_VALUE); + + unsafe.putIntVolatile(this, scanLengthOffset, counter); + return i; + } + } else { + // go to next record + i += RECORD_SIZE; + if (i >= table.length) { + if (failSafe) { + throw new IllegalStateException("No more space in linear probing table"); + } else { + i = 0; + failSafe = true; + } + } + } + } + } +} diff --git a/benchmark/src/jmh/java/io/ultrabrew/metrics/TagArrayBenchmark.java b/benchmark/src/jmh/java/io/ultrabrew/metrics/TagArrayBenchmark.java new file mode 100755 index 0000000..0cd1612 --- /dev/null +++ b/benchmark/src/jmh/java/io/ultrabrew/metrics/TagArrayBenchmark.java @@ -0,0 +1,155 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +import io.ultrabrew.metrics.util.TagArray; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.infra.Blackhole; + +@State(Scope.Benchmark) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class TagArrayBenchmark { + + public static final String KEY_1 = "key1"; + public static final String KEY_2 = "key2"; + public static final String KEY_3 = "key3"; + // Intentionally not local or final to disallow optimizing the vararg array construction + @SuppressWarnings("FieldCanBeLocal") + private String thing1 = "abc"; + @SuppressWarnings("FieldCanBeLocal") + private String thing2 = "abc"; + @SuppressWarnings("FieldCanBeLocal") + private String thing3 = "abc"; + + private TagArray tagArray; + private TagArray.VariableKey key1; + private TagArray.VariableKey key2; + private TagArray.VariableKey key3; + + private StringMapTagArray stringMapTagArray; + + @Setup + public void prepare() { + TagArray.Builder b = TagArray.builder(); + b.constant("abc", "def"); + key1 = b.variable(KEY_1); + key2 = b.variable(KEY_2); + key3 = b.variable(KEY_3); + tagArray = b.build(); + + StringMapTagArray.Builder bb = StringMapTagArray.builder(); + bb.constant("abc", "def"); + bb.variable(KEY_1); + bb.variable(KEY_2); + bb.variable(KEY_3); + stringMapTagArray = bb.build(); + } + + private void useArray(final Blackhole bh, final String... array) { + bh.consume(array); + } + + @Benchmark + public void testVarArgs(final Blackhole bh) { + useArray(bh, "abc", "def", KEY_1, thing1, KEY_2, thing2, KEY_3, thing3); + } + + @Benchmark + public void testTagArray(final Blackhole bh) { + tagArray.put(key1, thing1); + tagArray.put(key2, thing2); + tagArray.put(key3, thing3); + useArray(bh, tagArray.toArray()); + } + + @Benchmark + public void testStringMapTagArray(final Blackhole bh) { + stringMapTagArray.put(KEY_1, thing1); + stringMapTagArray.put(KEY_2, thing2); + stringMapTagArray.put(KEY_3, thing3); + useArray(bh, stringMapTagArray.toArray()); + } + + @Benchmark + public void testTagArrayPut() { + tagArray.put(key1, thing1); + } + + @Benchmark + public void testStringMapTagArrayPut() { + stringMapTagArray.put(KEY_1, thing1); + } + + public static final class StringMapTagArray { + + private final ThreadLocal array; + private final Map indices; + + private StringMapTagArray(final String[] array, final Map indices) { + this.array = ThreadLocal.withInitial(array::clone); + this.indices = indices; + } + + public static Builder builder() { + return new StringMapTagArray.Builder(); + } + + public void put(final String key, final String value) { + array.get()[indices.get(key)] = value; + } + + public String[] toArray() { + return array.get(); + } + + public static class Builder { + + private final Map tags = new HashMap<>(); + + private Builder() { + } + + public void constant(final String key, final String value) { + tags.put(key, value); + } + + public void variable(final String key) { + tags.put(key, null); + } + + public StringMapTagArray build() { + List> sortedByKey = tags.entrySet().stream() + .sorted(Comparator., String>comparing(Entry::getKey)) + .collect(Collectors.toList()); + String[] array = new String[sortedByKey.size() * 2]; + int i = 0; + Map indices = new HashMap<>(); + for (Map.Entry entry : sortedByKey) { + array[i] = entry.getKey(); + array[i + 1] = entry.getValue(); + if (entry.getValue() == null) { + indices.put(entry.getKey(), i + 1); + } + i += 2; + } + return new StringMapTagArray(array, indices); + } + } + } +} diff --git a/benchmark/src/jmh/java/io/ultrabrew/metrics/dropwizard/NoTagsBenchmark.java b/benchmark/src/jmh/java/io/ultrabrew/metrics/dropwizard/NoTagsBenchmark.java new file mode 100755 index 0000000..b5939f0 --- /dev/null +++ b/benchmark/src/jmh/java/io/ultrabrew/metrics/dropwizard/NoTagsBenchmark.java @@ -0,0 +1,104 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.dropwizard; + +import com.codahale.metrics.Slf4jReporter; +import io.ultrabrew.metrics.Counter; +import io.ultrabrew.metrics.Gauge; +import io.ultrabrew.metrics.MetricRegistry; +import io.ultrabrew.metrics.Timer; +import io.ultrabrew.metrics.reporters.SLF4JReporter; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.infra.Blackhole; +import org.slf4j.LoggerFactory; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Threads(50) +@State(Scope.Benchmark) +public class NoTagsBenchmark { + + private static final int CONSUME_CPU = 512; + + private MetricRegistry foundationRegistry; + private Counter foundationCounter; + private Timer foundationTimer; + private Gauge foundationGauge; + private SLF4JReporter foundationReporter; + + private com.codahale.metrics.MetricRegistry dropwizardRegistry; + private com.codahale.metrics.Counter dropwizardCounter; + private com.codahale.metrics.Timer dropwizardTimer; + private com.codahale.metrics.Histogram dropwizardHistogram; + private Slf4jReporter dropwizardReporter; + + private volatile long value = 0L; + + @Setup + public void setup() { + foundationRegistry = new MetricRegistry(); + foundationCounter = foundationRegistry.counter("counter"); + foundationTimer = foundationRegistry.timer("timer"); + foundationGauge = foundationRegistry.gauge("gauge"); + foundationReporter = new SLF4JReporter("foundation"); + foundationRegistry.addReporter(foundationReporter); + + dropwizardRegistry = new com.codahale.metrics.MetricRegistry(); + dropwizardCounter = dropwizardRegistry.counter("counter"); + dropwizardTimer = dropwizardRegistry.timer("timer"); + dropwizardHistogram = dropwizardRegistry.histogram("gauge"); + dropwizardReporter = Slf4jReporter.forRegistry(dropwizardRegistry) + .outputTo(LoggerFactory.getLogger("dropwizard")) + .convertRatesTo(TimeUnit.SECONDS) + .convertDurationsTo(TimeUnit.NANOSECONDS) + .build(); + } + + @Benchmark + public void counterFoundation() { + foundationCounter.inc(); + Blackhole.consumeCPU(CONSUME_CPU); + } + + @Benchmark + public void counterDropwizard() { + dropwizardCounter.inc(); + Blackhole.consumeCPU(CONSUME_CPU); + } + + @Benchmark + public void timerFoundation() { + final long startTime = foundationTimer.start(); + Blackhole.consumeCPU(CONSUME_CPU); + foundationTimer.stop(startTime); + } + + @Benchmark + public void timerDropwizard() { + final com.codahale.metrics.Timer.Context context = dropwizardTimer.time(); + Blackhole.consumeCPU(CONSUME_CPU); + context.stop(); + } + + @Benchmark + public void gaugeFoundation() { + foundationGauge.set(value++ % 100L); + Blackhole.consumeCPU(CONSUME_CPU); + } + + @Benchmark + public void gaugeDropwizard() { + dropwizardHistogram.update(value++ % 100L); + Blackhole.consumeCPU(CONSUME_CPU); + } +} diff --git a/benchmark/src/jmh/java/io/ultrabrew/metrics/dropwizard/TagsBenchmark.java b/benchmark/src/jmh/java/io/ultrabrew/metrics/dropwizard/TagsBenchmark.java new file mode 100755 index 0000000..2b005aa --- /dev/null +++ b/benchmark/src/jmh/java/io/ultrabrew/metrics/dropwizard/TagsBenchmark.java @@ -0,0 +1,118 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.dropwizard; + +import com.codahale.metrics.Slf4jReporter; +import io.ultrabrew.metrics.Counter; +import io.ultrabrew.metrics.Gauge; +import io.ultrabrew.metrics.MetricRegistry; +import io.ultrabrew.metrics.Timer; +import io.ultrabrew.metrics.reporters.SLF4JReporter; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.infra.Blackhole; +import org.slf4j.LoggerFactory; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Threads(50) +@State(Scope.Benchmark) +public class TagsBenchmark { + + private static final int CONSUME_CPU = 512; + + private static final String TAGNAME = "tagName"; + private static final String DROPWIZARD_COUNTER_TAGNAME_PREFIX = "counter.tagName."; + private static final String DROPWIZARD_TIMER_TAGNAME_PREFIX = "timer.tagName."; + private static final String DROPWIZARD_GAUGE_TAGNAME_PREFIX = "gauge.tagName."; + + private MetricRegistry foundationRegistry; + private Counter foundationCounter; + private Timer foundationTimer; + private Gauge foundationGauge; + private SLF4JReporter foundationReporter; + + private com.codahale.metrics.MetricRegistry dropwizardRegistry; + private Slf4jReporter dropwizardReporter; + + private volatile long value = 0L; + private volatile long tagValue = 0L; + + @Param({"1", "10", "100"}) + private int cardinality; + + @Setup + public void setup() { + foundationRegistry = new MetricRegistry(); + foundationCounter = foundationRegistry.counter("counter"); + foundationTimer = foundationRegistry.timer("timer"); + foundationGauge = foundationRegistry.gauge("gauge"); + foundationReporter = new SLF4JReporter("foundation"); + foundationRegistry.addReporter(foundationReporter); + + dropwizardRegistry = new com.codahale.metrics.MetricRegistry(); + dropwizardReporter = Slf4jReporter.forRegistry(dropwizardRegistry) + .outputTo(LoggerFactory.getLogger("dropwizard")) + .convertRatesTo(TimeUnit.SECONDS) + .convertDurationsTo(TimeUnit.NANOSECONDS) + .build(); + } + + @Benchmark + public void counterFoundation() { + foundationCounter.inc(TAGNAME, String.valueOf(tagValue++ % cardinality)); + Blackhole.consumeCPU(CONSUME_CPU); + } + + @Benchmark + public void counterDropwizard() { + final com.codahale.metrics.Counter counter = + dropwizardRegistry + .counter(DROPWIZARD_COUNTER_TAGNAME_PREFIX + String.valueOf(tagValue++ % cardinality)); + counter.inc(); + Blackhole.consumeCPU(CONSUME_CPU); + } + + @Benchmark + public void timerFoundation() { + final long startTime = foundationTimer.start(); + Blackhole.consumeCPU(CONSUME_CPU); + foundationTimer.stop(startTime, TAGNAME, String.valueOf(tagValue++ % cardinality)); + } + + @Benchmark + public void timerDropwizard() { + final com.codahale.metrics.Timer timer = + dropwizardRegistry + .timer(DROPWIZARD_TIMER_TAGNAME_PREFIX + String.valueOf(tagValue++ % cardinality)); + final com.codahale.metrics.Timer.Context context = timer.time(); + Blackhole.consumeCPU(CONSUME_CPU); + context.stop(); + } + + @Benchmark + public void gaugeFoundation() { + foundationGauge.set(value++ % 100L, TAGNAME, String.valueOf(tagValue++ % cardinality)); + Blackhole.consumeCPU(CONSUME_CPU); + } + + @Benchmark + public void gaugeDropwizard() { + final com.codahale.metrics.Histogram histogram = + dropwizardRegistry + .histogram(DROPWIZARD_GAUGE_TAGNAME_PREFIX + String.valueOf(tagValue++ % cardinality)); + + histogram.update(value++ % 100L); + Blackhole.consumeCPU(CONSUME_CPU); + } +} diff --git a/benchmark/src/jmh/resources/log4j2.xml b/benchmark/src/jmh/resources/log4j2.xml new file mode 100755 index 0000000..673d7c6 --- /dev/null +++ b/benchmark/src/jmh/resources/log4j2.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100755 index 0000000..e9ecca8 --- /dev/null +++ b/build.gradle @@ -0,0 +1,159 @@ +buildscript { + repositories { + maven { url "https://plugins.gradle.org/m2" } + } + + dependencies { + classpath group: 'com.bmuschko', name: 'gradle-clover-plugin', version: '2.2.1' + classpath group: 'com.github.ben-manes', name: 'gradle-versions-plugin', version: '0.20.0' + classpath group: 'me.champeau.gradle', name: 'jmh-gradle-plugin', version: '0.4.8' + classpath group: 'org.owasp', name: 'dependency-check-gradle', version: '4.0.0.1' + classpath group: 'gradle.plugin.com.github.spotbugs', name: 'spotbugs-gradle-plugin', version: '1.6.6' + classpath group: 'org.ajoberstar.reckon', name: 'reckon-gradle', version: '0.9.0' + } +} + +apply plugin: 'idea' +apply plugin: 'org.ajoberstar.reckon' +apply plugin: 'com.github.ben-manes.versions' + +description = """Measure behavior of critical components in production""" + +subprojects { + apply plugin: 'java' + apply plugin: 'maven-publish' + apply plugin: 'com.bmuschko.clover' + apply plugin: 'com.github.spotbugs' + apply plugin: 'org.owasp.dependencycheck' + + sourceCompatibility = 1.8 + targetCompatibility = 1.8 + + group = 'io.ultrabrew.' + rootProject.name + archivesBaseName = rootProject.name + '-' + name + + repositories { + jcenter() + } + + dependencies { + compileOnly 'com.github.spotbugs:spotbugs-annotations:3.1.9' + + testCompile group: 'org.jmockit', name: 'jmockit', version: '1.44' + testCompile group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: '5.3.2' + testRuntime group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: '5.3.2' + testCompile group: 'org.hamcrest', name: 'hamcrest-all', version: '1.3' + + clover group: 'org.openclover', name: 'clover', version: '4.2.1' + + spotbugsPlugins 'com.h3xstream.findsecbugs:findsecbugs-plugin:1.8.0' + + archives group: 'org.apache.maven.wagon', name: 'wagon-ssh-external', version: '3.2.0' + } + + test { + useJUnitPlatform() + + // Required as of JMockIt 1.42 + // https://jmockit.github.io/tutorial/Introduction.html#runningTests + doFirst { + jvmArgs "-javaagent:${classpath.find { it.name.contains("jmockit") }.absolutePath}" + } + + testLogging { + events 'passed', 'skipped', 'failed' + } + + reports { + html.enabled = true + } + } + + clover { + targetPercentage = '100.000%' + report { + html = true + testResultsDir = project.tasks.getByName('test').reports.junitXml.destination + testResultsInclude = 'TEST-*.xml' + } + } + + check.dependsOn cloverGenerateReport, dependencyCheckAnalyze + + dependencyCheck { + skipConfigurations = ['clover'] + format = 'ALL' + } + + spotbugs { + sourceSets = [sourceSets.main] + } + + spotbugsMain.reports { + xml.enabled = false + html.enabled = true + } + + spotbugsTest.reports { + xml.enabled = false + html.enabled = true + } + + task sourceJar(type: Jar) { + classifier = 'sources' + from sourceSets.main.allSource + } + + task javadocJar(type: Jar, dependsOn: javadoc) { + classifier = 'javadoc' + from javadoc.destinationDir + } + + javadoc.options { + noQualifiers << 'java.*' + } + + reckon { + scopeFromProp() + snapshotFromProp() + } + + publishing { + publications { + mavenJava(MavenPublication) { + from components.java + artifact sourceJar + artifact javadocJar + + afterEvaluate { + artifactId = archivesBaseName + } + } + } + + repositories { + maven { + def artifactory = 'https://oss.jfrog.org/artifactory/oss-snapshot-local' + def bintray = 'https://api.bintray.com/maven/ultrabrew/m2/' + rootProject.name + '/;publish=1' + + afterEvaluate { + url = version.toString().endsWith('-SNAPSHOT') ? artifactory : bintray + } + credentials { + username = System.getenv('BINTRAY_USER') + password = System.getenv('BINTRAY_API_KEY') + } + } + } + } + + // disable the tasks for the 'examples' aggregator project + gradle.taskGraph.whenReady { + gradle.taskGraph.allTasks.forEach { + if (it.project.name == 'examples') { + it.onlyIf { false } + } + } + } + +} \ No newline at end of file diff --git a/core/build.gradle b/core/build.gradle new file mode 100755 index 0000000..b7dd28a --- /dev/null +++ b/core/build.gradle @@ -0,0 +1,16 @@ +dependencies { + compile group: 'org.slf4j', name: 'slf4j-api', version:'1.7.25' + + testCompile group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.11.1' + testCompile group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.11.1' +} + +/*gitPublish { + repoUri = 'git@git@github.com:ultrabrew/metrics.git' + branch = 'gh-pages' + contents { + from(javadoc) { + into 'api' + } + } +}*/ diff --git a/core/src/main/java/io/ultrabrew/metrics/Counter.java b/core/src/main/java/io/ultrabrew/metrics/Counter.java new file mode 100755 index 0000000..121e7df --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/Counter.java @@ -0,0 +1,78 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +/** + * Counter increments or decrements a long value. + * + *
{@code
+ *     public class TestResource {
+ *         private static final String TAG_HOST = "host";
+ *         private static final String TAG_CLIENT = "client";
+ *         private final Counter errorCounter;
+ *         private final String hostName;
+ *
+ *         public TestResource(final MetricRegistry metricRegistry,
+ *                             final String hostName) {
+ *             errorCounter = metricRegistry.counter("errors");
+ *             this.hostName = hostName;
+ *         }
+ *
+ *         public void handleError(final String clientId) {
+ *             errorCounter.inc(TAG_CLIENT, clientId, TAG_HOST, hostName);
+ *
+ *             // .. do something ..
+ *         }
+ *     }
+ * }
+ * + * Note: The tag key-value array must always be sorted in the same order. + * + *

This class is thread-safe.

+ */ +public class Counter extends Metric { + + Counter(final MetricRegistry registry, final String id) { + super(registry, id); + } + + /** + * Increment the counter by 1. + * + * @param tags a sorted array of tag key-value pairs in a flattened array + */ + public void inc(final String... tags) { + emit(1L, tags); + } + + /** + * Decrement the counter by 1. + * + * @param tags a sorted array of tag key-value pairs in a flattened array + */ + public void dec(final String... tags) { + emit(-1L, tags); + } + + /** + * Increment the counter by given change value. + * + * @param change value by which to increment the counter + * @param tags a sorted array of tag key-value pairs in a flattened array + */ + public void inc(long change, final String... tags) { + emit(change, tags); + } + + /** + * Decrement the counter by given change value. + * + * @param change value by which to decrement the counter + * @param tags a sorted array of tag key-value pairs in a flattened array + */ + public void dec(long change, final String... tags) { + emit(-change, tags); + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/Gauge.java b/core/src/main/java/io/ultrabrew/metrics/Gauge.java new file mode 100755 index 0000000..75728cc --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/Gauge.java @@ -0,0 +1,52 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +/** + * Gauge measures a long value at given time. + * + *
{@code
+ *     public class TestResource {
+ *         private static final String TAG_HOST = "host";
+ *         private final Gauge cacheSizeGauge;
+ *         private final String hostName;
+ *         private final Map cache;
+ *
+ *         public TestResource(final MetricRegistry metricRegistry,
+ *                             final String hostName) {
+ *             cacheSizeGauge = metricRegistry.gauge("cacheSize");
+ *             this.hostName = hostName;
+ *             cache = new java.util.Map<>();
+ *         }
+ *
+ *         public void doSomething() {
+ *             cacheSizeGauge.set(cache.size(), TAG_HOST, hostName);
+ *         }
+ *     }
+ * }
+ * + * Note: The tag key-value array must always be sorted in the same order. + * + * TODO: Should we have instead lambda code that returns the value and we call it when we want to + * report this, we don't care about intermediate values anyway, or do we? + * + *

This class is thread-safe.

+ */ +public class Gauge extends Metric { + + Gauge(final MetricRegistry registry, final String id) { + super(registry, id); + } + + /** + * Measure the gauge's value. + * + * @param value set value of gauge + * @param tags a sorted array of tag key-value pairs + */ + public void set(final long value, final String... tags) { + emit(value, tags); + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/GaugeDouble.java b/core/src/main/java/io/ultrabrew/metrics/GaugeDouble.java new file mode 100755 index 0000000..5869e2b --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/GaugeDouble.java @@ -0,0 +1,48 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +/** + * GaugeDouble measures a double value at given time. + * + *
{@code
+ *     public class TestResource {
+ *         private static final String TAG_HOST = "host";
+ *         private final GaugeDouble cpuUsageGauge;
+ *         private final String hostName;
+ *
+ *         public TestResource(final MetricRegistry metricRegistry, final String hostName) {
+ *             this.cpuUsageGauge = metricRegistry.gaugeDouble("cpuUsage");
+ *             this.hostName = hostName;
+ *         }
+ *
+ *         public void doSomething() {
+ *             double d = getCpuUsage();
+ *             cpuUsageGauge.set(d, TAG_HOST, hostName);
+ *         }
+ *     }
+ * }
+ * + * Note: The tag key-value array must always be sorted in the same order. + * + *

This class is thread-safe.

+ */ +public class GaugeDouble extends Metric { + + GaugeDouble(final MetricRegistry registry, final String id) { + super(registry, id); + } + + /** + * Measure the gauge's value. + * + * @param value set value of gauge + * @param tags a sorted array of tag key-value pairs + */ + public void set(final double value, final String... tags) { + final long l = Double.doubleToRawLongBits(value); + emit(l, tags); + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/JvmStatisticsCollector.java b/core/src/main/java/io/ultrabrew/metrics/JvmStatisticsCollector.java new file mode 100755 index 0000000..8697f08 --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/JvmStatisticsCollector.java @@ -0,0 +1,170 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +import io.ultrabrew.metrics.util.Intervals; +import java.lang.management.BufferPoolMXBean; +import java.lang.management.ClassLoadingMXBean; +import java.lang.management.CompilationMXBean; +import java.lang.management.GarbageCollectorMXBean; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryPoolMXBean; +import java.lang.management.MemoryUsage; +import java.lang.management.ThreadMXBean; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +/** + * Collector of statistics related to the running Java Virtual Machine. + *

The statistics are reported as {@link Gauge} values with following identifiers:

+ *
+ *
jvm.classloading.loaded
+ *
Number of classes currently loaded into the JVM.
+ *
jvm.classloading.unloaded
+ *
Total number of classes unloaded since JVM started.
+ *
jvm.classloading.totalLoaded
+ *
Total number of classes loaded since JVM started.
+ *
jvm.compilation.totalTime
+ *
Approximate total time used for JIT compilation in milliseconds.
+ *
jvm.thread.daemonCount
+ *
Number of currently running daemon threads.
+ *
jvm.thread.count
+ *
Number of currently running threads.
+ *
jvm.thread.totalStartedCount
+ *
Total number of threads started in this JVM.
+ *
jvm.memorypool.<poolname>.committed
+ *
Amount of memory allocated from the OS for this memory pool in bytes.
+ *
jvm.memorypool.<poolname>.used
+ *
Amount of memory used for this memory pool in bytes.
+ *
jvm.bufferpool.<poolname>.count
+ *
Estimated number of buffers in this pool.
+ *
jvm.bufferpool.<poolname>.totalCapacity
+ *
Estimated total capacity of this pool in bytes.
+ *
jvm.bufferpool.<poolname>.memoryUsed
+ *
Estimated total memory used for this pool in bytes.
+ *
jvm.gc.<collectorname>.count
+ *
Total number of garbage collections for this collector.
+ *
jvm.gc.<collectorname>.time
+ *
Approximate total time used by this collector in milliseconds.
+ *
+ * + * @see ClassLoadingMXBean + * @see CompilationMXBean + * @see ThreadMXBean + * @see MemoryPoolMXBean + * @see BufferPoolMXBean + * @see GarbageCollectorMXBean + */ +public class JvmStatisticsCollector { + + private final MetricRegistry registry; + private final Map gaugeCache = new HashMap<>(); + private final Semaphore exitRequest = new Semaphore(0); + private Thread reportingThread = null; + + /** + * Create a JvmStatisticsCollector that emits statistics to the given {@link MetricRegistry}. + * + * @param registry registry to report statistics to + */ + public JvmStatisticsCollector(final MetricRegistry registry) { + this.registry = registry; + } + + /** + * Start collecting statistics at given intervals. + * + * @param intervalMillis data collection interval in milliseconds + * @throws IllegalStateException if already running + */ + public void start(long intervalMillis) { + if (reportingThread != null) { + throw new IllegalStateException("Already started"); + } + reportingThread = new Thread(() -> run(intervalMillis)); + reportingThread.setDaemon(true); + reportingThread.start(); + } + + private void run(long intervalMillis) { + while (true) { + try { + // Offset the collection time to middle of interval to avoid racing with TimeWindowReporter + // based reporters as they use the interval end + long delay = Intervals + .calculateDelay(intervalMillis, System.currentTimeMillis() + intervalMillis / 2); + if (exitRequest.tryAcquire(delay, TimeUnit.MILLISECONDS)) { + break; + } + collect(); + } catch (InterruptedException e) { + // Ignore, should not happen and just looping again is fine + } + } + } + + /** + * Stop collecting statistics. + * + * @throws IllegalStateException if not running + */ + public void stop() { + if (reportingThread == null) { + throw new IllegalStateException("Not started"); + } + try { + exitRequest.release(); + reportingThread.join(); + } catch (InterruptedException e) { + // CLOVER:OFF + // Unreachable unless something completely unexpected happens + throw new RuntimeException("Unexpected exception", e); + // CLOVER:ON + } + } + + private void setGauge(String id, long value) { + Gauge gauge = gaugeCache.computeIfAbsent(id, registry::gauge); + gauge.set(value); + } + + private void collect() { + ClassLoadingMXBean loadingBean = ManagementFactory.getClassLoadingMXBean(); + setGauge("jvm.classloading.loaded", loadingBean.getLoadedClassCount()); + setGauge("jvm.classloading.unloaded", loadingBean.getUnloadedClassCount()); + setGauge("jvm.classloading.totalLoaded", loadingBean.getTotalLoadedClassCount()); + + CompilationMXBean compilationBean = ManagementFactory.getCompilationMXBean(); + setGauge("jvm.compilation.totalTime", compilationBean.getTotalCompilationTime()); + + ThreadMXBean threadBean = ManagementFactory.getThreadMXBean(); + setGauge("jvm.thread.daemonCount", threadBean.getDaemonThreadCount()); + setGauge("jvm.thread.count", threadBean.getThreadCount()); + setGauge("jvm.thread.totalStartedCount", threadBean.getTotalStartedThreadCount()); + + for (MemoryPoolMXBean poolBean : ManagementFactory.getMemoryPoolMXBeans()) { + MemoryUsage usage = poolBean.getUsage(); + String prefix = "jvm.memorypool." + poolBean.getName().replace(' ', '_'); + setGauge(prefix + ".committed", usage.getCommitted()); + setGauge(prefix + ".used", usage.getUsed()); + } + + for (BufferPoolMXBean bufferPoolBean : ManagementFactory + .getPlatformMXBeans(BufferPoolMXBean.class)) { + String prefix = "jvm.bufferpool." + bufferPoolBean.getName().replace(' ', '_'); + setGauge(prefix + ".count", bufferPoolBean.getCount()); + setGauge(prefix + ".totalCapacity", bufferPoolBean.getTotalCapacity()); + setGauge(prefix + ".memoryUsed", bufferPoolBean.getMemoryUsed()); + } + + for (GarbageCollectorMXBean collectorBean : ManagementFactory.getGarbageCollectorMXBeans()) { + String prefix = "jvm.gc." + collectorBean.getName().replace(' ', '_'); + setGauge(prefix + ".count", collectorBean.getCollectionCount()); + setGauge(prefix + ".time", collectorBean.getCollectionTime()); + } + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/Metric.java b/core/src/main/java/io/ultrabrew/metrics/Metric.java new file mode 100755 index 0000000..995c220 --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/Metric.java @@ -0,0 +1,44 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +/** + * The base class of a system of measurement producing a single reportable metric. The system may + * consist of related measures that facilitates the quantification of some particular + * characteristic, but the end result is a single metric that is reported. Each measurement event is + * emitted to all subscribers through associated metric registry. + * + * @see MetricRegistry + */ +public abstract class Metric { + + /** + * Identifier of the metric + */ + public final String id; + + private final MetricRegistry registry; + + /** + * Create a metric associated with a metric registry. + * + * @param registry metric registry the metric is associated with + * @param id identifier of the metric + */ + protected Metric(final MetricRegistry registry, final String id) { + this.registry = registry; + this.id = id; + } + + /** + * Publish a measured value to all subscribers through the metric registry. + * + * @param value measurement value + * @param tags a sorted and flattened array of tag key-value pairs + */ + protected void emit(final long value, final String[] tags) { + registry.emit(this, System.currentTimeMillis(), value, tags); + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/MetricRegistry.java b/core/src/main/java/io/ultrabrew/metrics/MetricRegistry.java new file mode 100755 index 0000000..9141374 --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/MetricRegistry.java @@ -0,0 +1,149 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +import java.lang.reflect.InvocationTargetException; +import java.util.List; +import java.util.Map; + +/** + * A collection of metrics, which may be subscribed by a reporter. Each metric is always associated + * only with a single metric registry, but reporters may subscribe to multiple metric registries. + * Generally you only need one metric registry, although you may choose to use more if you need to + * organize your metrics in particular reporting groups or subscribe with different reporters. + * + *

MetricRegistry will always return you the same instance of a metric if it already exists for + * same identifier. Trying to create two different types of metrics for the same identifier will + * result into a {@link IllegalStateException}. You should create all the metric instances you need + * at the start of your application, and share them between your threads.

+ * + *

This class is thread-safe.

+ */ +public class MetricRegistry { + + private final Map measurements; + private final List reporters; + + /** + * Create a registry of metrics. + */ + public MetricRegistry() { + measurements = new java.util.HashMap<>(); + reporters = new java.util.ArrayList<>(); + } + + /** + * Return the {@link Counter} registered under this id; or create and register a new {@link + * Counter}. + * + * @param id identifier of the measurement + * @return a new or pre-existing {@link Counter} + * @throws IllegalStateException measurement with different type, but same identifier already + * exists + */ + public Counter counter(final String id) { + return getOrCreate(id, Counter.class); + } + + /** + * Return the {@link Gauge} registered under this id; or create and register a new {@link Gauge}. + * + * @param id identifier of the measurement + * @return a new or pre-existing {@link Gauge} + * @throws IllegalStateException measurement with different type, but same identifier already + * exists + */ + public Gauge gauge(final String id) { + return getOrCreate(id, Gauge.class); + } + + /** + * Return the {@link GaugeDouble} registered under this id; or create and register a new {@link + * GaugeDouble}. + * + * @param id identifier of the measurement + * @return a new or pre-existing {@link GaugeDouble} + * @throws IllegalStateException measurement with different type, but same identifier already + * exists + */ + public GaugeDouble gaugeDouble(final String id) { + return getOrCreate(id, GaugeDouble.class); + } + + /** + * Return the {@link Timer} registered under this id; or create and register a new {@link Timer}. + * + * @param id identifier of the measurement + * @return a new or pre-existing {@link Timer} + * @throws IllegalStateException measurement with different type, but same identifier already + * exists + */ + public Timer timer(final String id) { + return getOrCreate(id, Timer.class); + } + + /** + * Return a custom measurement registered under this id; or create and register a new custom + * measurement of given class. The class must have an accessible constructor that takes + * MetricRegistry and String as parameters. + * + * @param id identifier of the measurement + * @param klass custom measurement class extending Metric + * @param custom measurement class extending Metric + * @return a new or pre-existing custom measurement + * @throws IllegalStateException measurement with different type, but same identifier already + * exists + */ + public T custom(final String id, final Class klass) { + return getOrCreate(id, klass); + } + + /** + * Subscribe a reporter to all measurement events produced by the metrics in the metric registry. + * + * @param reporter reporter to subscribe + */ + public void addReporter(final Reporter reporter) { + reporters.add(reporter); + } + + private T getOrCreate(final String id, final Class klass) { + Metric m = measurements.get(id); + if (m != null) { + return tryCast(klass, m); + } + synchronized (measurements) { + m = measurements.get(id); + if (m != null) { + return tryCast(klass, m); + } + try { + T instance = klass.getDeclaredConstructor(MetricRegistry.class, String.class) + .newInstance(this, id); + measurements.put(id, instance); + return instance; + } catch (InstantiationException | IllegalAccessException | InvocationTargetException | + NoSuchMethodException e) { + throw new IllegalStateException("Could not create measurement: " + id, e); + } + } + } + + private T tryCast(Class klass, Metric m) throws IllegalStateException { + if (klass.isInstance(m)) { + return klass.cast(m); + } + throw new IllegalStateException( + "Metric '" + m.id + "' is already defined with different type: " + m.getClass()); + } + + //--- INTERNAL METHODS --- + + void emit(final Metric metric, final long timestamp, final long value, final String[] tags) { + for (final Reporter r : reporters) { + r.emit(metric, timestamp, value, tags); + } + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/Reporter.java b/core/src/main/java/io/ultrabrew/metrics/Reporter.java new file mode 100755 index 0000000..e96a7d7 --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/Reporter.java @@ -0,0 +1,27 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +import io.ultrabrew.metrics.reporters.AggregatingReporter; + +/** + * Reporter is a thread-safe consumer subscribing to measurement events in {@link MetricRegistry}. + * It may forward events as-is to external aggregation via, e.g., UDP datagrams, or choose to + * aggregate the events in-process. + * + * @see AggregatingReporter + */ +public interface Reporter { + + /** + * Consume subscribed measurement event. + * + * @param metric metric instance emitting the event + * @param timestamp update time, measured in milliseconds since midnight, January 1, 1970 UTC. + * @param value measurement value + * @param tags a sorted and flattened array of tag key-value pairs + */ + void emit(final Metric metric, final long timestamp, final long value, final String[] tags); +} diff --git a/core/src/main/java/io/ultrabrew/metrics/Timer.java b/core/src/main/java/io/ultrabrew/metrics/Timer.java new file mode 100755 index 0000000..6f7f191 --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/Timer.java @@ -0,0 +1,77 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +/** + * Timer measures time in nanoseconds between two events and acts as a counter to count the + * events. + * + *
{@code
+ *     public class TestResource {
+ *         private static final String TAG_HOST = "host";
+ *         private static final String TAG_CLIENT = "client";
+ *         private static final String TAG_STATUS = "status";
+ *         private final Timer requestTimer;
+ *         private final String hostName;
+ *
+ *         public TestResource(final MetricRegistry metricRegistry,
+ *                             final String hostName) {
+ *             requestTimer = metricRegistry.timer("requests");
+ *             this.hostName = hostName;
+ *         }
+ *
+ *         public void handleRequest(final String clientId) {
+ *             final long startTime = requestTimer.start();
+ *             int statusCode;
+ *
+ *             // .. handle request ..
+ *
+ *             // Note: no need for separate counter for requests per sec, as count is already included
+ *             requestTimer.stop(startTime, TAG_CLIENT, clientId, TAG_HOST, hostName, TAG_STATUS,
+ *                               String.valueOf(statusCode));
+ *         }
+ *     }
+ * }
+ * + * Note: The tag key-value array must always be sorted in the same order. + * + *

This class is thread-safe.

+ */ +public class Timer extends Metric { + + Timer(final MetricRegistry registry, final String id) { + super(registry, id); + } + + /** + * Start the timer. + * + * @return start time in nanoseconds + */ + public long start() { + return System.nanoTime(); + } + + /** + * Stop and update the timer. + * + * @param startTime start time in nanoseconds from {@link #start()} + * @param tags a sorted and flattened array of tag key-value pairs + */ + public void stop(final long startTime, final String... tags) { + final long duration = System.nanoTime() - startTime; + emit(duration, tags); + } + + /** + * Update the timer. + * + * @param duration duration in nanoseconds + * @param tags a sorted and flattened array of tag key-value pairs + */ + public void update(final long duration, final String... tags) { + emit(duration, tags); + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/data/Aggregator.java b/core/src/main/java/io/ultrabrew/metrics/data/Aggregator.java new file mode 100755 index 0000000..0931c97 --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/data/Aggregator.java @@ -0,0 +1,40 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +/** + * Aggregator is a thread-safe aggregator for a single metric's measurement values. It may use + * multiple different aggregation functions to calculate multiple fields on the given values. For + * example, same aggregator can calculate the min, max, average and sum of given values. Aggregator + * may be dependent on the time interval of reporter, i.e. when reading a field, it may reset it, or + * the implementation might update the current time interval values, but allow read only to the last + * time interval's aggregates. + */ +public interface Aggregator { + + /** + * Apply aggregation functions to given value identified with a given a tag set. An aggregator may + * ignore some or all of the tag key-value pairs to reduce cardinality. + * + * @param tags a sorted array of tag key-value pairs in a flattened array or an empty array + * @param value measurement value + * @param timestamp update time, measured in milliseconds since midnight, January 1, 1970 UTC. + */ + void apply(final String[] tags, final long value, final long timestamp); + + /** + * Retrieve a cursor to iterate all rows in the aggregator. + * + * @return a cursor + */ + Cursor cursor(); + + /** + * Retrieve a sorted cursor to iterate all rows in the aggregator. + * + * @return a cursor sorted lexically by the tag sets + */ + Cursor sortedCursor(); +} diff --git a/core/src/main/java/io/ultrabrew/metrics/data/BasicCounterAggregator.java b/core/src/main/java/io/ultrabrew/metrics/data/BasicCounterAggregator.java new file mode 100755 index 0000000..8234013 --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/data/BasicCounterAggregator.java @@ -0,0 +1,62 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +import io.ultrabrew.metrics.Counter; + +/** + * A monoid for common aggregation functions for a Counter metric class. + * + *

Performs the following aggregation functions on the measured values:

+ *
    + *
  • sum of the values
  • + *
+ * + * @see Counter + */ +public class BasicCounterAggregator extends ConcurrentMonoidHashTable { + + private static final String[] FIELDS = {"sum"}; + private static final Type[] TYPES = {Type.LONG}; + private static final long[] IDENTITY = {0L}; + private static final int DEFAULT_CAPACITY = 128; + + /** + * Create a monoid for common aggregation functions for a Counter. + * + * @param metricId identifier of the metric associated with this aggregator + */ + public BasicCounterAggregator(final String metricId) { + this(metricId, DEFAULT_CAPACITY); + } + + /** + * Create a monoid for common aggregation functions for a Counter with requested capacity. + * + * @param metricId identifier of the metric associated with this aggregator + * @param capacity requested capacity of table in records, actual capacity may be higher + */ + public BasicCounterAggregator(final String metricId, final int capacity) { + super(metricId, capacity, FIELDS, TYPES, IDENTITY); + } + + /** + * Create a monoid for common aggregation functions for a Counter with requested initial capacity + * and max capacity. + * + * @param metricId identifier of the metric associated with this aggregator + * @param capacity requested capacity of table in records, actual capacity may be higher + * @param maxCapacity requested max capacity of table in records. Table doesn't grow beyond this + * value. + */ + public BasicCounterAggregator(final String metricId, final int capacity, final int maxCapacity) { + super(metricId, capacity, FIELDS, TYPES, IDENTITY, maxCapacity); + } + + @Override + public void combine(final long[] table, final long baseOffset, final long value) { + add(table, baseOffset, 0, value); + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/data/BasicGaugeAggregator.java b/core/src/main/java/io/ultrabrew/metrics/data/BasicGaugeAggregator.java new file mode 100755 index 0000000..3a88bdf --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/data/BasicGaugeAggregator.java @@ -0,0 +1,70 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +import io.ultrabrew.metrics.Gauge; + +/** + * A monoid for common aggregation functions for a Gauge metric class. + * + *

Performs the following aggregation functions on the measurements:

+ *
    + *
  • count of measurements
  • + *
  • sum of the measurement values
  • + *
  • minimum measured value
  • + *
  • maximum measured value
  • + *
  • last measured value
  • + *
+ * + * @see Gauge + */ +public class BasicGaugeAggregator extends ConcurrentMonoidHashTable { + + private static final String[] FIELDS = {"count", "sum", "min", "max", "lastValue"}; + private static final Type[] TYPES = {Type.LONG, Type.LONG, Type.LONG, Type.LONG, Type.LONG}; + private static final long[] IDENTITY = {0L, 0L, Long.MAX_VALUE, Long.MIN_VALUE, 0L}; + private static final int DEFAULT_CAPACITY = 128; + + /** + * Create a monoid for common aggregation functions for a Counter. + * + * @param metricId identifier of the metric associated with this aggregator + */ + public BasicGaugeAggregator(final String metricId) { + this(metricId, DEFAULT_CAPACITY); + } + + /** + * Create a monoid for common aggregation functions for a Counter with requested capacity. + * + * @param metricId identifier of the metric associated with this aggregator + * @param capacity requested capacity of table in records, actual capacity may be higher + */ + public BasicGaugeAggregator(final String metricId, final int capacity) { + super(metricId, capacity, FIELDS, TYPES, IDENTITY); + } + + /** + * Create a monoid for common aggregation functions for a Counter with requested initial capacity + * and max capacity. + * + * @param metricId identifier of the metric associated with this aggregator + * @param capacity requested capacity of table in records, actual capacity may be higher + * @param maxCapacity requested max capacity of table in records. Table doesn't grow beyond this + * value. + */ + public BasicGaugeAggregator(final String metricId, final int capacity, final int maxCapacity) { + super(metricId, capacity, FIELDS, TYPES, IDENTITY, maxCapacity); + } + + @Override + public void combine(final long[] table, final long baseOffset, final long value) { + add(table, baseOffset, 0, 1L); + add(table, baseOffset, 1, value); + min(table, baseOffset, 2, value); + max(table, baseOffset, 3, value); + set(table, baseOffset, 4, value); + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/data/BasicGaugeDoubleAggregator.java b/core/src/main/java/io/ultrabrew/metrics/data/BasicGaugeDoubleAggregator.java new file mode 100755 index 0000000..b9ee0f6 --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/data/BasicGaugeDoubleAggregator.java @@ -0,0 +1,73 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +import io.ultrabrew.metrics.GaugeDouble; + +/** + * A monoid for common aggregation functions for a GaugeDouble metric class. + * + *

Performs the following aggregation functions on the measurements:

+ *
    + *
  • count of measurements
  • + *
  • sum of the measurement values
  • + *
  • minimum measured value
  • + *
  • maximum measured value
  • + *
  • last measured value
  • + *
+ * + * @see GaugeDouble + */ +public class BasicGaugeDoubleAggregator extends ConcurrentMonoidHashTable { + + private static final String[] FIELDS = {"count", "sum", "min", "max", "lastValue"}; + private static final Type[] TYPES = + {Type.LONG, Type.DOUBLE, Type.DOUBLE, Type.DOUBLE, Type.DOUBLE}; + private static final long[] IDENTITY = {0L, 0L, Long.MAX_VALUE, Long.MIN_VALUE, 0L}; + private static final int DEFAULT_CAPACITY = 128; + + /** + * Create a monoid for common aggregation functions for a Counter. + * + * @param metricId identifier of the metric associated with this aggregator + */ + public BasicGaugeDoubleAggregator(final String metricId) { + this(metricId, DEFAULT_CAPACITY); + } + + /** + * Create a monoid for common aggregation functions for a Counter with requested capacity. + * + * @param metricId identifier of the metric associated with this aggregator + * @param capacity requested capacity of table in records, actual capacity may be higher + */ + public BasicGaugeDoubleAggregator(final String metricId, final int capacity) { + super(metricId, capacity, FIELDS, TYPES, IDENTITY); + } + + /** + * Create a monoid for common aggregation functions for a Counter with requested initial capacity + * and max capacity. + * + * @param metricId identifier of the metric associated with this aggregator + * @param capacity requested capacity of table in records, actual capacity may be higher + * @param maxCapacity requested max capacity of table in records. Table doesn't grow beyond this + * value. + */ + public BasicGaugeDoubleAggregator(final String metricId, final int capacity, + final int maxCapacity) { + super(metricId, capacity, FIELDS, TYPES, IDENTITY, maxCapacity); + } + + @Override + public void combine(final long[] table, final long baseOffset, final long value) { + final double d = Double.longBitsToDouble(value); + add(table, baseOffset, 0, 1L); + add(table, baseOffset, 1, d); + min(table, baseOffset, 2, d); + max(table, baseOffset, 3, d); + set(table, baseOffset, 4, d); + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/data/BasicTimerAggregator.java b/core/src/main/java/io/ultrabrew/metrics/data/BasicTimerAggregator.java new file mode 100755 index 0000000..309f586 --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/data/BasicTimerAggregator.java @@ -0,0 +1,68 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +import io.ultrabrew.metrics.Gauge; + +/** + * A monoid for common aggregation functions for a Timer metric class. + * + *

Performs the following aggregation functions on the measurements:

+ *
    + *
  • count of measurements
  • + *
  • sum of the measurement values
  • + *
  • minimum measured value
  • + *
  • maximum measured value
  • + *
+ * + * @see Gauge + */ +public class BasicTimerAggregator extends ConcurrentMonoidHashTable { + + private static final String[] FIELDS = {"count", "sum", "min", "max"}; + private static final Type[] TYPES = {Type.LONG, Type.LONG, Type.LONG, Type.LONG}; + private static final long[] IDENTITY = {0L, 0L, Long.MAX_VALUE, Long.MIN_VALUE}; + private static final int DEFAULT_CAPACITY = 128; + + /** + * Create a monoid for common aggregation functions for a Counter. + * + * @param metricId identifier of the metric associated with this aggregator + */ + public BasicTimerAggregator(final String metricId) { + this(metricId, DEFAULT_CAPACITY); + } + + /** + * Create a monoid for common aggregation functions for a Counter with requested capacity. + * + * @param metricId identifier of the metric associated with this aggregator + * @param capacity requested capacity of table in records, actual capacity may be higher + */ + public BasicTimerAggregator(final String metricId, final int capacity) { + super(metricId, capacity, FIELDS, TYPES, IDENTITY); + } + + /** + * Create a monoid for common aggregation functions for a Counter with requested initial capacity + * and max capacity. + * + * @param metricId identifier of the metric associated with this aggregator + * @param capacity requested capacity of table in records, actual capacity may be higher + * @param maxCapacity requested max capacity of table in records. Table doesn't grow beyond this + * value + */ + public BasicTimerAggregator(final String metricId, final int capacity, final int maxCapacity) { + super(metricId, capacity, FIELDS, TYPES, IDENTITY, maxCapacity); + } + + @Override + public void combine(final long[] table, final long baseOffset, final long value) { + add(table, baseOffset, 0, 1L); + add(table, baseOffset, 1, value); + min(table, baseOffset, 2, value); + max(table, baseOffset, 3, value); + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/data/ConcurrentMonoidHashTable.java b/core/src/main/java/io/ultrabrew/metrics/data/ConcurrentMonoidHashTable.java new file mode 100755 index 0000000..46158de --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/data/ConcurrentMonoidHashTable.java @@ -0,0 +1,693 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sun.misc.Unsafe; + +/** + * A simple thread-safe linear probing hash table to be used for a monoid operation to aggregate + * measurement values. + * + *

The hash table stores each tag set as a separate record within the hash table. When {@link + * #apply(String[], long, + * long)} is called, the hash table will use the {@link #index(String[], boolean)} method to find + * any existing record or create a new record if none found. If a new record is created it will be + * initialized with initialized {@link #identity} to store the monoid identity in the left hand + * value, before applying the monoid binary operation with {@link #combine(long[], long, + * long)}.

+ * + *

For performance reasons, the monoid implementations may choose to avoid atomic operation on + * the whole record, and + * may apply the binary operation or setting the identity for each field in the record separately. + * This may cause small inaccuracies in the combined values, if two or more threads are + * simultaneously setting the identity and applying the binary operation. It is guaranteed that only + * one thread sets the identity at a time, but simultaneous applications of binary operations are + * not prevented during setting the identity. Similarly, when iterating the hash table with cursor, + * the reading and writing may interleave, which may cause slight inaccuracy.

+ * + *

The monoid implementation may choose to ignore some tags by overriding the {@link + * #hashCode(String[])} method to + * skip any tag key-value pairs the aggregator is not interested on.

+ * + *

All implementing classes MUST be thread-safe.

+ */ +public abstract class ConcurrentMonoidHashTable implements Aggregator { + + private static final Logger LOGGER = LoggerFactory.getLogger(ConcurrentMonoidHashTable.class); + + /** + * Unsafe class for atomic operations on the hash table. + */ + private static final Unsafe unsafe = UnsafeHelper.unsafe; + + private static final int DEFAULT_MAX_CAPACITY = 4096; //4k + + private static final int TAGSETS_MAX_INCREMENT = 131072; // 128k + + private static final int RESERVED_FIELDS = 2; + private static final long usedOffset; + + private static final int DEFAULT_INITIAL_CAPACITY = 16; + private static final float DEFAULT_LOAD_FACTOR = 0.7f; + + // Certainty required to meet the spec of probablePrime + private static final int DEFAULT_PRIME_CERTAINTY = 100; + + private static final long TABLE_MASK = 0x0FFFFFFF00000000l; + private static final long SLOT_MASK = 0x00000000FFFFFFFFl; + private static final long NOT_FOUND = 0x1000000000000000l; + + + ///CLOVER:OFF + // Turning off clover because Unsafe can't be safely mocked without crashing or otherwise + // hindering JVM, and Class can't be mocked + static { + try { + usedOffset = unsafe + .objectFieldOffset(ConcurrentMonoidHashTable.class.getDeclaredField("used")); + } catch (NoSuchFieldException e) { + throw new Error(e); + } + } + ///CLOVER:ON + + + /** + * Identifier of the metric this hash table is associated with. + */ + public final String metricId; + + /** + * The multiple long array objects to manipulate with the unsafe atomic operations. + */ + private List tables; + + /** + * Number of entries in the corresponding table in {@link #tables} + */ + private List recordCounts; + + /** + * Max number of entries allowed in the corresponding table in {@link #tables}. + */ + private List tableCapacities; + + /** + * The monoid's identity + */ + private final long[] identity; + + /** + * An array of tag sets currently contained within the hash table. + */ + private volatile String[][] tagSets; + + private final int recordSize; + private final String[] fields; + private final Type[] types; + private final int maxCapacity; + private volatile int capacity; + + private volatile int used = 0; + + /** + * Create a simple linear probing hash table for a monoid operation. + * + * @param metricId identifier of the metric + * @param initialCapacity requested capacity of table in records, actual capacity may be higher + * @param fields sorted array of field names used in reporting + * @param types type of each corresponding field + * @param identity monoid's identity, where each value is corresponding to the given fields names + */ + protected ConcurrentMonoidHashTable(final String metricId, final int initialCapacity, + final String[] fields, final Type[] types, final long[] identity) { + this(metricId, initialCapacity, fields, types, identity, DEFAULT_MAX_CAPACITY); + } + + /** + * @param metricId identifier of the metric + * @param initialCapacity requested capacity of table in records + * @param fields sorted array of field names used in reporting + * @param types type of each corresponding field + * @param identity monoid's identity, where each value is corresponding to the given fields names + * @param maxCapacity maximum capacity of table in records. + */ + protected ConcurrentMonoidHashTable(final String metricId, int initialCapacity, + final String[] fields, final Type[] types, final long[] identity, final int maxCapacity) { + + if (fields.length != identity.length || fields.length == 0) { + throw new IllegalArgumentException( + "Fields and Identity must match in length and be non-zero"); + } + if (initialCapacity < 0) { + throw new IllegalArgumentException("Illegal initial capacity"); + } + if (maxCapacity < initialCapacity) { + throw new IllegalArgumentException( + "max capacity should be greater than the initial capacity"); + } + if (initialCapacity == 0) { + initialCapacity = DEFAULT_INITIAL_CAPACITY; + } + + this.metricId = metricId; + // Align to L1 cache line (64-byte) + this.recordSize = (((fields.length + RESERVED_FIELDS) >> 3) + 1) << 3; + this.fields = fields; + this.types = types; + this.identity = identity; + this.maxCapacity = maxCapacity; + this.capacity = initialCapacity; + this.tables = new ArrayList<>(); + this.recordCounts = new ArrayList<>(); + this.tableCapacities = new ArrayList<>(); + this.tagSets = new String[initialCapacity][]; + addTable(initialCapacity, sizeTableFor(initialCapacity)); + } + + @Override + public void apply(final String[] tags, final long value, final long timestamp) { + + final long index = index(tags, false); + + // Failed to grow table, silently drop the measurement + if (index == NOT_FOUND) { + return; + } + + // decodes two int values, table index and slot index from a long. The higher 32 bits represent the table index and lower 32 bits represent the slot index. + // This logic is replicated in multiple places for performance reasons. + int tableIndex = (int) ((index & TABLE_MASK) >> 32); + int slotIndex = (int) (index & SLOT_MASK); + long[] table = tables.get(tableIndex); + + final long base = Unsafe.ARRAY_LONG_BASE_OFFSET + slotIndex * Unsafe.ARRAY_LONG_INDEX_SCALE; + unsafe.putLongVolatile(table, base + Unsafe.ARRAY_LONG_INDEX_SCALE, timestamp); + + combine(table, base, value); + } + + @Override + public Cursor cursor() { + return new CursorImpl(tagSets, fields, types, false); + } + + @Override + public Cursor sortedCursor() { + return new CursorImpl(tagSets, fields, types, true); + } + + /** + * Returns the number of elements in this hash table + * + * @return the number of elements in this hash table + */ + public int size() { + return unsafe.getInt(this, usedOffset); + } + + /** + * Returns the current maximum number of elements this hash table is able to store + * + * @return the current capacity in elements + */ + public int capacity() { + return capacity; + } + + /** + * Execute the monoid binary operation on given value to the record with the given base offset in + * the table. + * + *

The following methods have been provided to allow atomic thread-safe modification of fields + * in the record

+ * + *
    + *
  • {@link #set(long[], long, long, long)} - Replace field's value with given value
  • + *
  • {@link #add(long[], long, long, long)} - Add given value to field's existing value
  • + *
  • {@link #min(long[], long, long, long)} - Replace field's value, if its larger than given + * value
  • + *
  • {@link #max(long[], long, long, long)} - Replace field's value, if its smaller than given + * value
  • + *
+ * + * @param table data container to be passed to the modification method + * @param baseOffset base offset of the record in the table containing the left hand value + * @param value right hand value to apply the monoid binary operation + */ + protected abstract void combine(long[] table, final long baseOffset, final long value); + + + /** + * Find index of the record for the given key in the linear probing table. + * + *

When a slot in the table is taken, it will never be released nor changed.

+ * + * @param tags key to use for table + * @return index position in the table for the record. The 64 bit long value has two int values encoded into it. + * The higher 32 bits represent the table index and the lower 32 bits represent the slot index. + * And returns {@link #NOT_FOUND} if record not found. + */ + long index(final String[] tags, final boolean isReading) { + final long key = hashCode(tags); + for (int tableIndex = 0; tableIndex < tables.size(); tableIndex++) { + long[] table = tables.get(tableIndex); + AtomicInteger recordCount = recordCounts.get(tableIndex); + int tableCapacity = tableCapacities.get(tableIndex); + final int slot = getSlot(key, table.length / recordSize); + final int startIndex = slot * recordSize; + int slotIndex = startIndex; + for (; ; ) { + long offset = Unsafe.ARRAY_LONG_BASE_OFFSET + slotIndex * Unsafe.ARRAY_LONG_INDEX_SCALE; + long candidate = unsafe.getLongVolatile(table, offset); + + // check if we found our key + if (key == candidate) { + // encodes two int values, table index and slot index into a long. The higher 32 bits represent the table index and lower 32 bits represent the slot index. + // This logic is replicated in multiple places for performance reasons. + return ((long)tableIndex) << 32 | ((long)slotIndex); + } + + boolean emptySlot = 0L == candidate; + + if (emptySlot) { + + if (isReading) { + break; // If the slot is empty while reading, skip to the next table. + } else if (recordCount.get() >= tableCapacity) { + break; // we're writing but the table is 70% full + } else { + ///CLOVER:OFF + // No reliable way to test without being able to mock unsafe + if (unsafe.compareAndSwapLong(table, offset, 0L, key)) { // try to reserve it + ///CLOVER:ON + + //increment the record count + recordCount.incrementAndGet(); + + // reset update timestamp + unsafe.putLongVolatile(table, offset + Unsafe.ARRAY_LONG_INDEX_SCALE, 0L); + // It is ok if we lose some data from other threads while writing identity + for (int j = 0; j < identity.length; j++) { + unsafe.putLongVolatile(table, + offset + (RESERVED_FIELDS + j) * Unsafe.ARRAY_LONG_INDEX_SCALE, identity[j]); + } + + //increment the total size; + int tagIndex = unsafe.getAndAddInt(this, usedOffset, 1); + if (tagIndex >= tagSets.length) { + // grow tag set + synchronized (this) { + if (tagIndex >= tagSets.length) { + final int oldLength = tagSets.length; + final int newLength = oldLength > TAGSETS_MAX_INCREMENT ? oldLength + TAGSETS_MAX_INCREMENT : oldLength * 2; + tagSets = Arrays.copyOf(tagSets, newLength); + } + } + } + + // Store tags in the tag array for iteration purposes only + tagSets[tagIndex] = tags; + + // encodes two int values, table index and slot index into a long. The higher 32 bits represent the table index and lower 32 bits represent the slot index. + // This logic is replicated in multiple places for performance reasons. + return ((long)tableIndex) << 32 | ((long)slotIndex); + } + } + } else { + slotIndex += recordSize; + if (slotIndex >= table.length) { + slotIndex = 0; + } + if (slotIndex == startIndex) { + break; // check the next table + } + } + } + } + if (isReading) { + return NOT_FOUND; + } else { + if (growTable()) { + return index(tags, isReading); + } else { + return NOT_FOUND; + } + } + } + + private boolean growTable() { + + if (capacity >= maxCapacity) { + LOGGER.error("Maximum linear probing table capacity reached"); + return false; + } + + synchronized (this) { + int lastTableIndex = tables.size() - 1; + int lastRecordCount = recordCounts.get(lastTableIndex).get(); + int lastTableCapacity = tableCapacities.get(lastTableIndex); + if (lastRecordCount >= lastTableCapacity && capacity < maxCapacity) { + int nextTableCapacity = nextTableCapacity(lastTableCapacity); + int newCapacity = capacity + nextTableCapacity; + addTable(nextTableCapacity, sizeTableFor(nextTableCapacity)); + capacity = newCapacity; + } + } + return true; + } + + /** + * @param tableCapacity, max number of entries allowed in this table + * @param tableSize, actual table length. Which is around 30% more than the capacity. + */ + private void addTable(final int tableCapacity, final int tableSize) { + tables.add(new long[tableSize * recordSize]); + recordCounts.add(new AtomicInteger()); + tableCapacities.add(tableCapacity); + } + + private int nextTableCapacity(final int lastTableCapacity) { + int nextTableCapacity; + if (capacity < maxCapacity / 2) { + nextTableCapacity = Math.min(lastTableCapacity << 1, maxCapacity - capacity); // double the size; + } else { + nextTableCapacity = Math.min(lastTableCapacity, maxCapacity - capacity); // grow linearly + } + return nextTableCapacity; + } + + /** + * Adds extra 30% and round it the next probable prime to avoid performance degradation of the + * linear probing table. + */ + private int sizeTableFor(final int capacity) { + int tableSize = (int) (capacity * (1 + (1 - DEFAULT_LOAD_FACTOR))); + BigInteger bigInteger = BigInteger.valueOf(tableSize); + if (!bigInteger.isProbablePrime(DEFAULT_PRIME_CERTAINTY)) { + tableSize = bigInteger.nextProbablePrime().intValueExact(); + } + return tableSize; + } + + private int getSlot(final long key, final int tableSize) { + int slot = Math.abs((int) key) % tableSize; + return slot < 0 ? 0 : slot; + } + + /** + * Adds a new value to the existing value in the given field index. + * + * @param table the table containing the values + * @param baseOffset base offset of the record in the table containing the left hand value + * @param index index of the field + * @param value value to be added + * @return new accumulated value of the field index + */ + protected long add(final long[] table, final long baseOffset, final long index, + final long value) { + final long offset = baseOffset + (RESERVED_FIELDS + index) * Unsafe.ARRAY_LONG_INDEX_SCALE; + return unsafe.getAndAddLong(table, offset, value) + value; + } + + /** + * Adds a new double value to the existing value in the given field index. + * + * @param table the table containing the values + * @param baseOffset base offset of the record in the table containing the left hand value + * @param index index of the field + * @param value value to be added + * @return new accumulated value of the field index + */ + protected double add(final long[] table, final long baseOffset, final long index, + final double value) { + final long offset = ((RESERVED_FIELDS + index) * Unsafe.ARRAY_LONG_INDEX_SCALE) + baseOffset; + long old; + double old_d, new_d; + do { + old = unsafe.getLongVolatile(table, offset); + old_d = Double.longBitsToDouble(old); + new_d = old_d + value; + ///CLOVER:OFF + // No reliable way to test without being able to mock unsafe + } while (!unsafe.compareAndSwapLong(table, offset, old, Double.doubleToRawLongBits(new_d))); + ///CLOVER:ON + return new_d; + } + + /** + * Replaces the existing value in the given field index with the given value. + * + * @param table the table containing the values + * @param baseOffset base offset of the record in the table containing the left hand value + * @param index index of the field + * @param value new value + */ + protected void set(final long[] table, final long baseOffset, final long index, + final long value) { + final long offset = baseOffset + (RESERVED_FIELDS + index) * Unsafe.ARRAY_LONG_INDEX_SCALE; + unsafe.putLongVolatile(table, offset, value); + } + + /** + * Replaces the existing double value in the given field index with the given value. + * + * @param table the table containing the values + * @param baseOffset base offset of the record in the table containing the left hand value + * @param index index of the field + * @param value new value + */ + protected void set(final long[] table, final long baseOffset, final long index, + final double value) { + final long offset = baseOffset + (RESERVED_FIELDS + index) * Unsafe.ARRAY_LONG_INDEX_SCALE; + unsafe.putLongVolatile(table, offset, Double.doubleToLongBits(value)); + } + + /** + * Set a new value as minimum if its lower than existing value in the given field index. + * + * @param table the table containing the values + * @param baseOffset base offset of the record in the table containing the left hand value + * @param index index of the field + * @param value new value + */ + protected void min(final long[] table, final long baseOffset, final long index, + final long value) { + final long offset = baseOffset + (RESERVED_FIELDS + index) * Unsafe.ARRAY_LONG_INDEX_SCALE; + long old; + do { + old = unsafe.getLong(table, offset); + if (value >= old) { + return; + } + ///CLOVER:OFF + // No reliable way to test without being able to mock unsafe + } while (!unsafe.compareAndSwapLong(table, offset, old, value)); + ///CLOVER:ON + } + + /** + * Set a new double value as minimum if its lower than existing value in the given field index. + * + * @param table the table containing the values + * @param baseOffset base offset of the record in the table containing the left hand value + * @param index index of the field + * @param value new value + */ + protected void min(final long[] table, final long baseOffset, final long index, + final double value) { + final long offset = baseOffset + (RESERVED_FIELDS + index) * Unsafe.ARRAY_LONG_INDEX_SCALE; + long old; + double old_d; + do { + old = unsafe.getLong(table, offset); + old_d = Double.longBitsToDouble(old); + if (value >= old_d) { + return; + } + ///CLOVER:OFF + // No reliable way to test without being able to mock unsafe + } while (!unsafe.compareAndSwapLong(table, offset, old, Double.doubleToRawLongBits(value))); + ///CLOVER:ON + } + + /** + * Set a new value as maximum if its higher than existing value in the given field index. + * + * @param table the table containing the values + * @param baseOffset base offset of the record in the table containing the left hand value + * @param index index of the field + * @param value new value + */ + protected void max(final long[] table, final long baseOffset, final long index, + final long value) { + final long offset = baseOffset + (RESERVED_FIELDS + index) * Unsafe.ARRAY_LONG_INDEX_SCALE; + long old; + do { + old = unsafe.getLong(table, offset); + if (value <= old) { + return; + } + ///CLOVER:OFF + // No reliable way to test without being able to mock unsafe + } while (!unsafe.compareAndSwapLong(table, offset, old, value)); + ///CLOVER:ON + } + + /** + * Set a new double value as maximum if its higher than existing value in the given field index. + * + * @param table the table containing the values + * @param baseOffset base offset of the record in the table containing the left hand value + * @param index index of the field + * @param value new value + */ + protected void max(final long[] table, final long baseOffset, final long index, + final double value) { + final long offset = baseOffset + (RESERVED_FIELDS + index) * Unsafe.ARRAY_LONG_INDEX_SCALE; + long old; + double old_d; + do { + old = unsafe.getLong(table, offset); + old_d = Double.longBitsToDouble(old); + if (value <= old_d) { + return; + } + ///CLOVER:OFF + // No reliable way to test without being able to mock unsafe + } while (!unsafe.compareAndSwapLong(table, offset, old, Double.doubleToRawLongBits(value))); + ///CLOVER:ON + } + + /** + * Filter any given tags and return a hash code + * + * @param tags a flattened array of tag key-value pairs + * @return hash code + */ + long hashCode(final String[] tags) { + return Arrays.hashCode(tags); + } + + private class CursorImpl implements Cursor { + + final private String[] fields; + final private Type[] types; + final private String[][] tagSets; + private int i = -1; + private long base = 0; + private long[] table; + + private CursorImpl(final String[][] tagSets, final String[] fields, final Type[] types, + final boolean sorted) { + this.fields = fields; + this.types = types; + if (sorted) { + this.tagSets = tagSets.clone(); + Arrays.sort(this.tagSets, TagSetsHelper::compare); + } else { + this.tagSets = tagSets; + } + } + + @Override + public String getMetricId() { + return metricId; + } + + @Override + public boolean next() { + i++; + if (i >= tagSets.length || tagSets[i] == null) { + return false; + } + + long index = index(tagSets[i], true); + + if (NOT_FOUND == index) { + LOGGER.error("Missing index on Read. Tags: {}. Concurrency error or bug", + Arrays.asList(tagSets[i])); + return false; + } + + // decodes two int values, table index and slot index from a long. The higher 32 bits represent the table index and lower 32 bits represent the slot index. + // This logic is replicated in multiple places for performance reasons. + int tableIndex = (int) ((index & TABLE_MASK) >> 32); + int slotIndex = (int) (index & SLOT_MASK); + + table = tables.get(tableIndex); + base = Unsafe.ARRAY_LONG_BASE_OFFSET + slotIndex * Unsafe.ARRAY_LONG_INDEX_SCALE; + return true; + } + + @Override + public String[] getTags() { + if (i < 0 || i >= tagSets.length || tagSets[i] == null) { + throw new IndexOutOfBoundsException("Not a valid row index: " + i); + } + return tagSets[i]; + } + + @Override + public long lastUpdated() { + if (i < 0 || i >= tagSets.length || tagSets[i] == null) { + throw new IndexOutOfBoundsException("Not a valid row index: " + i); + } + return unsafe.getLongVolatile(table, base + Unsafe.ARRAY_LONG_INDEX_SCALE); + } + + @Override + public long readLong(final int index) { + if (i < 0 || i >= tagSets.length || tagSets[i] == null) { + throw new IndexOutOfBoundsException("Not a valid row index: " + i); + } + if (index < 0 || index >= fields.length) { + throw new IndexOutOfBoundsException("Not a valid field index: " + index); + } + return unsafe + .getLongVolatile(table, base + (index + RESERVED_FIELDS) * Unsafe.ARRAY_LONG_INDEX_SCALE); + } + + @Override + public double readDouble(final int index) { + return Double.longBitsToDouble(readLong(index)); + } + + @Override + public long readAndResetLong(final int index) { + if (i < 0 || i >= tagSets.length || tagSets[i] == null) { + throw new IndexOutOfBoundsException("Not a valid row index: " + i); + } + if (index < 0 || index >= fields.length) { + throw new IndexOutOfBoundsException("Not a valid field index: " + index); + } + return unsafe + .getAndSetLong(table, base + (index + RESERVED_FIELDS) * Unsafe.ARRAY_LONG_INDEX_SCALE, + identity[index]); + } + + @Override + public double readAndResetDouble(final int index) { + return Double.longBitsToDouble(readAndResetLong(index)); + } + + @Override + public String[] getFields() { + return fields; + } + + @Override + public Type[] getTypes() { + return types; + } + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/data/Cursor.java b/core/src/main/java/io/ultrabrew/metrics/data/Cursor.java new file mode 100755 index 0000000..85ec0ef --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/data/Cursor.java @@ -0,0 +1,32 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +/** + * A Cursor object used to iterate the contents of an aggregator. + * + *

Initially the cursor is positioned before the first row. The next method moves the cursor to + * the next row, and + * because it returns false when there are no more rows in the Cursor object, it can be used in a + * while loop to iterate through the contents.

+ * + *

A cursor instance IS NOT thread-safe and should be used only from a single thread. + * Multiple cursor instances may + * be used in their own threads.

+ */ +public interface Cursor extends CursorEntry { + + /** + * Moves the cursor forward one row from its current position. + * + *

A cursor is initially positioned before the first row; the first call to the method next + * makes the + * first row the current row; the second call makes the second row the current row, and so + * on.

+ * + * @return true if the new current row is valid; false if there are no more rows + */ + boolean next(); +} diff --git a/core/src/main/java/io/ultrabrew/metrics/data/CursorEntry.java b/core/src/main/java/io/ultrabrew/metrics/data/CursorEntry.java new file mode 100755 index 0000000..0492594 --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/data/CursorEntry.java @@ -0,0 +1,85 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +public interface CursorEntry { + + /** + * Retrieve the metric identifier. + * + * @return identifier of the metric + */ + String getMetricId(); + + /** + * Retrieves tags in the current row. + * + * @return a sorted array of tag key-value pairs in a flattened array + */ + String[] getTags(); + + /** + * Retrieves the time when the current row was last updated. + * + * @return last updated time, measured in milliseconds since midnight, January 1, 1970 UTC. + */ + long lastUpdated(); + + /** + * Retrieves a long value for a field value at given index. + * + * @param index index of the field + * @return value of the field + */ + long readLong(final int index); + + /** + * Retrieves a double value for a field value at given index. + * + * @param index index of the field + * @return value of the field + */ + double readDouble(final int index); + + /** + * Retrieves a long value for a field value at given index, and resets the field's value to + * monoid's identity. + * + * @param index index of the field + * @return value of the field + */ + long readAndResetLong(final int index); + + /** + * Retrieves a double value for a field value at given index, and resets the field's value to + * monoid's identity. + * + * @param index index of the field + * @return value of the field + */ + double readAndResetDouble(final int index); + + /** + * Retrieves fields available in all rows. + * + *

The fields names and values are always in same order, and you can use the index of the field + * name array to + * retrieve the corresponding value of the field.

+ * + * @return a sorted array of field names + */ + String[] getFields(); + + /** + * Retrieves types of the fields available is all rows. + * + *

The types and fields are always in same order, and you can use the index of the type array + * to + * retrieve the corresponding field of the row.

+ * + * @return a sorted array of type of fields + */ + Type[] getTypes(); +} diff --git a/core/src/main/java/io/ultrabrew/metrics/data/MultiCursor.java b/core/src/main/java/io/ultrabrew/metrics/data/MultiCursor.java new file mode 100755 index 0000000..c370e09 --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/data/MultiCursor.java @@ -0,0 +1,81 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.Collection; + +public class MultiCursor { + + private final Cursor[] cursors; + private final String[][] nextTagSets; + + private String[] next; + private int cursorIndex = -1; + + public MultiCursor(final Collection aggregators) { + cursors = new Cursor[aggregators.size()]; + nextTagSets = new String[aggregators.size()][]; + initialize(aggregators); + } + + public boolean next() { + // Update cursors + if (next != null) { + for (int i = 0, l = cursors.length; i < l; i++) { + if (nextTagSets[i] != null && TagSetsHelper.compare(next, nextTagSets[i]) == 0) { + nextTagSets[i] = cursors[i].next() ? cursors[i].getTags() : null; + } + } + } + + // Clear the indices + next = null; + cursorIndex = -1; + + // Find the next lexical tag set + for (int i = 0, l = cursors.length; i < l; i++) { + if (nextTagSets[i] != null) { + if (next == null || TagSetsHelper.compare(nextTagSets[i], next) < 0) { + next = nextTagSets[i]; + } + } + } + + return next != null; + } + + @SuppressFBWarnings( + value = {"EI_EXPOSE_REP"}, + justification = "Avoid creating copies for performance reasons.") + public String[] getTags() { + return next; + } + + public CursorEntry nextCursorEntry() { + if (next == null || cursorIndex >= cursors.length) { + return null; + } + cursorIndex++; + for (int l = cursors.length; cursorIndex < l; cursorIndex++) { + if (TagSetsHelper.compare(next, nextTagSets[cursorIndex]) == 0) { + return cursors[cursorIndex]; + } + } + return null; + } + + private void initialize(final Collection aggregators) { + int i = 0; + for (final Aggregator aggregator : aggregators) { + final Cursor c = aggregator.sortedCursor(); + cursors[i] = c; + if (c != null && c.next()) { + nextTagSets[i] = c.getTags(); + } + i++; + } + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/data/TagSetsHelper.java b/core/src/main/java/io/ultrabrew/metrics/data/TagSetsHelper.java new file mode 100755 index 0000000..0b5f53c --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/data/TagSetsHelper.java @@ -0,0 +1,50 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +class TagSetsHelper { + + /** + * Compares its two arguments for order. Returns a negative integer, zero, or a positive integer + * as the first argument is less than, equal to, or greater than the second. + * + * @param o1 the first object to be compared + * @param o2 the second object to be compared + * @return a negative integer, zero, or a positive integer as the first argument is less than, + * equal to, or greater than the second. + */ + static int compare(final String[] o1, final String[] o2) { + if (o1 == null && o2 == null) { + return 0; + } + if (o2 == null) { + return -1; + } + if (o1 == null) { + return 1; + } + + final int len1 = o1.length; + final int len2 = o2.length; + final int lim = Math.min(len1, len2); + + for (int k = 0; k < lim; k++) { + if (o1[k] == null && o2[k] != null) { + return 1; + } + if (o1[k] != null && o2[k] == null) { + return -1; + } + if (o1[k] != null && o2[k] != null) { + int i = o1[k].compareTo(o2[k]); + if (i != 0) { + return i; + } + } + } + + return len1 - len2; + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/data/Type.java b/core/src/main/java/io/ultrabrew/metrics/data/Type.java new file mode 100755 index 0000000..9dc5fca --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/data/Type.java @@ -0,0 +1,21 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +public enum Type { + LONG { + @Override + public String readAndReset(final CursorEntry cursorEntry, final int index) { + return String.valueOf(cursorEntry.readAndResetLong(index)); + } + }, DOUBLE { + @Override + public String readAndReset(final CursorEntry cursorEntry, final int index) { + return String.valueOf(cursorEntry.readAndResetDouble(index)); + } + }; + + public abstract String readAndReset(final CursorEntry cursorEntry, final int index); +} diff --git a/core/src/main/java/io/ultrabrew/metrics/data/UnsafeHelper.java b/core/src/main/java/io/ultrabrew/metrics/data/UnsafeHelper.java new file mode 100755 index 0000000..35a04ea --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/data/UnsafeHelper.java @@ -0,0 +1,26 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +import java.lang.reflect.Field; +import sun.misc.Unsafe; + +/** + * An internal helper class to access {@link Unsafe}. + */ +public class UnsafeHelper { + + public static final Unsafe unsafe = getUnsafe(); + + static Unsafe getUnsafe() { + try { + Field f = Unsafe.class.getDeclaredField("theUnsafe"); + f.setAccessible(true); + return (Unsafe) f.get(null); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException("Failed to get unsafe instance, are you running Oracle JDK?", e); + } + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/reporters/AggregatingReporter.java b/core/src/main/java/io/ultrabrew/metrics/reporters/AggregatingReporter.java new file mode 100755 index 0000000..49511b4 --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/reporters/AggregatingReporter.java @@ -0,0 +1,189 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.reporters; + +import io.ultrabrew.metrics.Counter; +import io.ultrabrew.metrics.Gauge; +import io.ultrabrew.metrics.GaugeDouble; +import io.ultrabrew.metrics.Metric; +import io.ultrabrew.metrics.Reporter; +import io.ultrabrew.metrics.Timer; +import io.ultrabrew.metrics.data.Aggregator; +import io.ultrabrew.metrics.data.BasicCounterAggregator; +import io.ultrabrew.metrics.data.BasicGaugeAggregator; +import io.ultrabrew.metrics.data.BasicGaugeDoubleAggregator; +import io.ultrabrew.metrics.data.BasicTimerAggregator; +import io.ultrabrew.metrics.data.Cursor; +import io.ultrabrew.metrics.data.Type; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +/** + * A base class of a reporter that aggregates in-process the measurement events. This base class + * allows different {@link Aggregator}s for different metric classes and allows overriding the + * default aggregator based on the metric's identifier. + * + *

Default aggregators are

+ *
    + *
  • {@link BasicCounterAggregator} for {@link Counter}
  • + *
  • {@link BasicGaugeAggregator} for {@link Gauge}
  • + *
  • {@link BasicGaugeDoubleAggregator} for {@link GaugeDouble}
  • + *
  • {@link BasicTimerAggregator} for {@link Timer}
  • + *
+ * + *

All unknown metric classes will get a {@link #NOOP} no-op aggregation that ignores the + * metric.

+ */ +public abstract class AggregatingReporter implements Reporter { + + private static final String[] NO_TAGS = new String[]{}; + + private static final Cursor EMPTY_CURSOR = new Cursor() { + @Override + public boolean next() { + return false; + } + + // CLOVER:OFF + @Override + public String getMetricId() { + throw new IllegalStateException("Cursor has no valid data"); + } + + @Override + public String[] getTags() { + throw new IllegalStateException("Cursor has no valid data"); + } + + @Override + public long lastUpdated() { + throw new IllegalStateException("Cursor has no valid data"); + } + + @Override + public long readLong(int index) { + throw new IllegalStateException("Cursor has no valid data"); + } + + @Override + public double readDouble(int index) { + throw new IllegalStateException("Cursor has no valid data"); + } + + @Override + public long readAndResetLong(int index) { + throw new IllegalStateException("Cursor has no valid data"); + } + + @Override + public double readAndResetDouble(int index) { + throw new IllegalStateException("Cursor has no valid data"); + } + + @Override + public String[] getFields() { + throw new IllegalStateException("Cursor has no valid data"); + } + + @Override + public Type[] getTypes() { + throw new IllegalStateException("Cursor has no valid data"); + } + // CLOVER:ON + }; + + /** + * No-operation aggregator that ignores the measurement events and returns an empty cursor. + */ + public static final Aggregator NOOP = new Aggregator() { + @Override + public void apply(String[] tags, long value, long timestamp) { + // noop + } + + @Override + public Cursor cursor() { + return EMPTY_CURSOR; + } + + @Override + public Cursor sortedCursor() { + return EMPTY_CURSOR; + } + + }; + + /** + * Default aggregators for the default metrics. When creating custom metrics and custom + * aggregators for them, you should include these aggregators in the default aggregator list given + * to {@link #AggregatingReporter(Map)}. + */ + public static final Map, Function> DEFAULT_AGGREGATORS = + Collections + .unmodifiableMap( + new java.util.HashMap, Function>() {{ + put(Counter.class, BasicCounterAggregator::new); + put(Gauge.class, BasicGaugeAggregator::new); + put(GaugeDouble.class, BasicGaugeDoubleAggregator::new); + put(Timer.class, BasicTimerAggregator::new); + }}); + + /** + * Concurrent map of aggregators for each metric. The key of the map is the identifier of the + * metric and the value is the aggregator to be used for that metric. + */ + protected final ConcurrentHashMap aggregators; + + private final Map, Function> defaultAggregators; + + /** + * Create an aggregating reporter with default aggregators for default metrics only. + */ + protected AggregatingReporter() { + this(DEFAULT_AGGREGATORS); + } + + /** + * Create an aggregating reporter with given default aggregators. + * + * @param defaultAggregators a map of a metric class to a supplier creating a new aggregator + * instance + */ + protected AggregatingReporter( + final Map, Function> defaultAggregators) { + aggregators = new ConcurrentHashMap<>(); + this.defaultAggregators = Collections.unmodifiableMap(defaultAggregators); + } + + @Override + public void emit(final Metric metric, final long timestamp, final long value, + final String[] tags) { + Aggregator aggregator = aggregators.get(metric.id); + if (aggregator == null) { + aggregator = aggregators.computeIfAbsent(metric.id, (k) -> createAggregator(metric, k)); + } + aggregator.apply(tags != null ? tags : NO_TAGS, value, timestamp); + } + + /** + * Create a new aggregator for a metric. Called when an measurement event from previously unseen + * metric is received in this aggregator. + * + * @param metric metric instance producing the measurement event + * @param metricId identifier of the metric + * @return a new aggregator for the given metric. If metric should not be aggregated, the NOOP + * aggregator must be returned. + */ + protected Aggregator createAggregator(final Metric metric, final String metricId) { + // TODO: Implement override by metric name + Function supplier = defaultAggregators.get(metric.getClass()); + if (supplier == null) { + return NOOP; + } + return supplier.apply(metricId); + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/reporters/SLF4JReporter.java b/core/src/main/java/io/ultrabrew/metrics/reporters/SLF4JReporter.java new file mode 100755 index 0000000..516c0a9 --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/reporters/SLF4JReporter.java @@ -0,0 +1,148 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.reporters; + +import io.ultrabrew.metrics.data.Aggregator; +import io.ultrabrew.metrics.data.Cursor; +import io.ultrabrew.metrics.data.CursorEntry; +import io.ultrabrew.metrics.data.Type; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An aggregating SLF4J Logger reporter. This reporter uses SLF4J Logger with given name to log the + * aggregated values of the metrics. + * + *

The log message format is: {@code "{tags}{tagFieldDelimiter}{fields} {metricId}"}, where

+ *
    + *
  • {@code tags} is a list of {@code tagKey '=' tagValue} pairs delimited by given tag delimiter + * (default is + * {@code " "}).
  • + *
  • {@code tagFieldDelimiter} separates the tags and fields by given delimiter (default is {@code + * " "}.
  • + *
  • {@code fields} is a list of {@code fieldName '=' fieldValue} pairs delimited by given field + * delimiter + * (default is {@code " "}).
  • + *
  • {@code metricId} is the identifier of the metric.
  • + *
+ * + *

This reporter IS NOT intended to be used in production environments, and is only + * provided for debugging + * purposes.

+ */ +public class SLF4JReporter extends TimeWindowReporter { + + private Logger reporter; + private CharSequence tagDelimiter; + private CharSequence fieldDelimiter; + private CharSequence tagFieldDelimiter; + private long lastSeenTimestamp; + + /** + * Create a SLF4J Logger reporter. + * + * @param name name of the logger + */ + public SLF4JReporter(final String name) { + this(name, " ", " ", " ", DEFAULT_WINDOW_STEP_SIZE_SEC); + } + + /** + * Create a SLF4J Logger reporter with a custom window size + * + * @param name name of the logger. + * @param windowSizeSeconds window size in seconds + */ + public SLF4JReporter(final String name, final int windowSizeSeconds) { + this(name, " ", " ", " ", windowSizeSeconds); + } + + /** + * Create a SLF4J Logger reporter with given delimiters. + * + * @param name name of the logger + * @param tagDelimiter delimiter to be used to join tag key-value pairs + * @param fieldDelimiter delimiter to be used to join field name-value pairs + * @param tagFieldDelimiter delimiter to be used to separate tags and fields + * @param windowSizeSeconds window size in seconds + */ + public SLF4JReporter(final String name, final CharSequence tagDelimiter, + final CharSequence fieldDelimiter, + final CharSequence tagFieldDelimiter, final int windowSizeSeconds) { + + super(name, windowSizeSeconds); + + reporter = LoggerFactory.getLogger(name); + this.tagDelimiter = tagDelimiter; + this.fieldDelimiter = fieldDelimiter; + this.tagFieldDelimiter = tagFieldDelimiter; + this.lastSeenTimestamp = 0; + this.start(); + } + + + /** + * Manually force the reporter to output the current state of all the aggregators into the SLF4J + * Logger. + */ + @Override + protected void doReport(Map aggregators) { + long newestTimestamp = 0; + for (final Map.Entry entry : aggregators.entrySet()) { + final Aggregator aggregator = entry.getValue(); + final Cursor cursor = aggregator.cursor(); + final String metricName = entry.getKey(); + while (cursor.next()) { + if (cursor.lastUpdated() > lastSeenTimestamp) { + reporter.info("lastUpdated={} {}{}{} {}", + cursor.lastUpdated(), + formatTags(cursor.getTags()), + tagFieldDelimiter, + formatFields(cursor), + metricName); + newestTimestamp = Math.max(newestTimestamp, cursor.lastUpdated()); + } + } + } + if (newestTimestamp > 0) { + lastSeenTimestamp = newestTimestamp; + } + } + + private String formatTags(final String[] tags) { + StringBuilder sb = null; + + for (int i = 0; i < tags.length; i += 2) { + if (sb == null) { + sb = new StringBuilder(); + } else { + sb.append(tagDelimiter); + } + sb.append(tags[i]); + sb.append('='); + sb.append(tags[i + 1]); + } + return sb == null ? "" : sb.toString(); + } + + private String formatFields(final CursorEntry cursor) { + final String[] fields = cursor.getFields(); + final Type[] types = cursor.getTypes(); + StringBuilder sb = null; + + for (int i = 0; i < fields.length; i++) { + if (sb == null) { + sb = new StringBuilder(); + } else { + sb.append(fieldDelimiter); + } + sb.append(fields[i]); + sb.append('='); + sb.append(types[i].readAndReset(cursor, i)); + } + return sb == null ? "" : sb.toString(); + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/reporters/TimeWindowReporter.java b/core/src/main/java/io/ultrabrew/metrics/reporters/TimeWindowReporter.java new file mode 100755 index 0000000..3660676 --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/reporters/TimeWindowReporter.java @@ -0,0 +1,157 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.reporters; + +import static io.ultrabrew.metrics.reporters.AggregatingReporter.DEFAULT_AGGREGATORS; + +import io.ultrabrew.metrics.Metric; +import io.ultrabrew.metrics.Reporter; +import io.ultrabrew.metrics.data.Aggregator; +import io.ultrabrew.metrics.util.Intervals; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public abstract class TimeWindowReporter implements Reporter, Runnable, AutoCloseable { + + private Logger logger = LoggerFactory.getLogger(TimeWindowReporter.class); + + public static final int DEFAULT_WINDOW_STEP_SIZE_SEC = 60; + + private static final int PADDING_MILLIS = 100; + + private final String name; + private final long windowStepSizeMillis; + + private AtomicInteger threadId; + private volatile Thread reportingThread; + + private AggregatingReporter[] reporters = new AggregatingReporter[2]; + + public TimeWindowReporter(final String name) { + this(name, DEFAULT_WINDOW_STEP_SIZE_SEC); + } + + public TimeWindowReporter(final String name, final int windowStepSizeSeconds) { + this(name, windowStepSizeSeconds, DEFAULT_AGGREGATORS); + } + + public TimeWindowReporter(final String name, final int windowStepSizeSeconds, + final Map, Function> aggregators) { + this.name = name; + this.windowStepSizeMillis = windowStepSizeSeconds * 1000; + this.reporters[0] = new AggregatingReporter(aggregators) { + }; + this.reporters[1] = new AggregatingReporter(aggregators) { + }; + this.threadId = new AtomicInteger(1); + } + + + @Override + public void emit(final Metric metric, final long timestamp, final long value, + final String[] tags) { + AggregatingReporter writer = reporters[getWriterIndex(timestamp)]; + writer.emit(metric, timestamp, value, tags); + } + + protected void report() { + long currentTimeMillis = System.currentTimeMillis(); + AggregatingReporter reader = reporters[getReaderIndex(currentTimeMillis)]; + doReport(reader.aggregators); + } + + /** + * Shut down the reporter and release resources. + */ + @Override + public void close() { + stop(); + } + + /** + * The subclass has to provide the implementation to read the state of all the aggregators and + * report. + * + * @param aggregators mapping from metric id to aggregator + */ + protected abstract void doReport(Map aggregators); + + /** + * Place holder method to reset the underline data structure for the next window. + */ + private void init() { + } + + @Override + public void run() { + int id = this.threadId.get(); + String threadName = getThreadName(id); + logger.info("Starting {}", threadName); + + while (id == this.threadId.get()) { + try { + long startTimeInMillis = System.currentTimeMillis(); + // adding few extra millis to make sure it doesn't start reporting on a window that's still + // being written to + long delayMillis = + Intervals.calculateDelay(windowStepSizeMillis, startTimeInMillis) + PADDING_MILLIS; + + try { + Thread.sleep(delayMillis); + } catch (InterruptedException ignored) { + if ((System.currentTimeMillis() - startTimeInMillis) < delayMillis) { + continue; + } + } + report(); + } catch (Throwable t) { + logger.error("Error reporting metrics", t); + } + init(); + } + + logger.info("Ending {}", threadName); + } + + protected void start() { + synchronized (this) { + if (!isRunning()) { + reportingThread = new Thread(this, getThreadName(threadId.get())); + reportingThread.setDaemon(true); + reportingThread.start(); + } else { + throw new IllegalStateException("Already started"); + } + } + } + + protected void stop() { + synchronized (this) { + if (isRunning()) { + this.threadId.incrementAndGet(); + } + } + } + + protected boolean isRunning() { + return null != reportingThread + && reportingThread.getName().equals(getThreadName(threadId.get())); + } + + private String getThreadName(int threadId) { + return name + "-" + threadId; + } + + private int getWriterIndex(final long milliseconds) { + return ((milliseconds / windowStepSizeMillis) & 1) == 0 ? 0 : 1; + } + + private int getReaderIndex(final long milliseconds) { + return getWriterIndex(milliseconds) == 0 ? 1 : 0; + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/util/Intervals.java b/core/src/main/java/io/ultrabrew/metrics/util/Intervals.java new file mode 100755 index 0000000..7632a41 --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/util/Intervals.java @@ -0,0 +1,12 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.util; + +public class Intervals { + + public static long calculateDelay(final long interval, final long currentTime) { + return interval - (currentTime % interval); + } +} diff --git a/core/src/main/java/io/ultrabrew/metrics/util/TagArray.java b/core/src/main/java/io/ultrabrew/metrics/util/TagArray.java new file mode 100755 index 0000000..af44bc7 --- /dev/null +++ b/core/src/main/java/io/ultrabrew/metrics/util/TagArray.java @@ -0,0 +1,178 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.util; + +import io.ultrabrew.metrics.Metric; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +/** + * Helper class for handling tag arrays. + *

Using this class ensures the keys are always in same order and minimizes temporary objects + * created.

+ *
{@code
+ *     public class TestResource {
+ *         private static final String TAG_HOST = "host";
+ *         private static final String TAG_RESOURCE = "resource";
+ *
+ *         private final TagArray tagArray;
+ *         private final VariableKey resourceKey;
+ *
+ *         private final GaugeDouble cpuUsageGauge;
+ *
+ *         public TestResource(final MetricRegistry metricRegistry, final String hostName) {
+ *             TagArray.Builder b = TagArray.builder();
+ *             b.constant(TAG_HOST, hostName);
+ *             this.resourceKey = b.variable(TAG_RESOURCE);
+ *             this.tagArray = b.build();
+ *
+ *             this.cpuUsageGauge = metricRegistry.gaugeDouble("cpuUsage");
+ *         }
+ *
+ *         public void doSomething(String resource) {
+ *             double d = getCpuUsage();
+ *             tagArray.put(resourceKey, resource);
+ *             cpuUsageGauge.set(d, tagArray.toArray());
+ *         }
+ *     }
+ * }
+ */ +public class TagArray { + + private final ThreadLocal array; + + private TagArray(final String[] array) { + this.array = ThreadLocal.withInitial(array::clone); + } + + /** + * Create a new builder for constructing a {@link TagArray}. + * + * @return new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Set the value for given key in the tag array. + *

If all values are not set before {@link #toArray()} is called the result is undefined.

+ *

Must be called for each {@link VariableKey} in the {@link TagArray} immediately before + * calling {@link #toArray()} in the same thread.

+ *

Note: The key must be related to this {@link TagArray}.

+ * + * @param key key to set value for + * @param value the new value + */ + public void put(final VariableKey key, final String value) { + array.get()[key.index] = value; + } + + /** + * Get the raw array representation of this {@link TagArray} for passing to a {@link Metric}. + * + * @return the raw array + */ + public String[] toArray() { + return array.get(); + } + + private abstract static class Key { + + protected String getInitialValue() { + return null; + } + + protected void setIndex(final int index) { + } + } + + /** + * Builder class for constructing {@link TagArray} instances. + */ + public static class Builder { + + private final Map tags = new HashMap<>(); + + private Builder() { + } + + /** + * Add a new key/value pair to the array with constant value. + * + * @param key the key + * @param value the constant value + */ + public void constant(final String key, final String value) { + tags.put(key, new ConstantKey(value)); + } + + /** + * Add a new key with variable value set later to the array. + * + * @param key the key + * @return key token to be passed to {@link #put(VariableKey, String)} + */ + public VariableKey variable(final String key) { + VariableKey holder = new VariableKey(); + tags.put(key, holder); + return holder; + } + + /** + * Build a new {@link TagArray} with the keys and values configured to this {@link Builder}. + * + * @return a new {@link TagArray} instance + */ + public TagArray build() { + List> sortedByKey = tags.entrySet().stream() + .sorted(Comparator., String>comparing(Entry::getKey)) + .collect(Collectors.toList()); + String[] array = new String[sortedByKey.size() * 2]; + int i = 0; + for (Map.Entry entry : sortedByKey) { + array[i] = entry.getKey(); + array[i + 1] = entry.getValue().getInitialValue(); + entry.getValue().setIndex(i + 1); + i += 2; + } + return new TagArray(array); + } + } + + private static final class ConstantKey extends Key { + + private final String value; + + private ConstantKey(final String value) { + this.value = value; + } + + @Override + public String getInitialValue() { + return value; + } + } + + /** + * A token representing a key in the {@link TagArray}. + * + * @see #put(VariableKey, String) + * @see Builder#variable(String) + */ + public static final class VariableKey extends Key { + + private int index; + + @Override + protected void setIndex(final int index) { + this.index = index; + } + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/CounterTest.java b/core/src/test/java/io/ultrabrew/metrics/CounterTest.java new file mode 100755 index 0000000..ae770b9 --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/CounterTest.java @@ -0,0 +1,106 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.arrayContaining; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import mockit.Expectations; +import mockit.Verifications; +import org.junit.jupiter.api.Test; + +public class CounterTest { + + @Test + public void testCounter() { + MetricRegistry metricRegistry = new MetricRegistry(); + Counter c = metricRegistry.counter("test"); + + new Expectations(c) {{ + c.emit(anyLong, (String[]) any); + }}; + + c.inc(); + + new Verifications() {{ + String[] tags; + c.emit(1L, tags = withCapture()); + assertEquals(0, tags.length); + }}; + + c.inc("TEST-key", "test-value"); + + new Verifications() {{ + String[] tags; + c.emit(1L, tags = withCapture()); + assertThat(tags, arrayContaining("TEST-key", "test-value")); + }}; + + c.dec(); + + new Verifications() {{ + String[] tags; + c.emit(-1L, tags = withCapture()); + assertEquals(0, tags.length); + }}; + + c.dec("TEST-key", "test-value"); + + new Verifications() {{ + String[] tags; + c.emit(-1L, tags = withCapture()); + assertThat(tags, arrayContaining("TEST-key", "test-value")); + }}; + + c.inc(100L); + + new Verifications() {{ + String[] tags; + c.emit(100L, tags = withCapture()); + assertEquals(0, tags.length); + }}; + + c.dec(101L); + + new Verifications() {{ + String[] tags; + c.emit(-101L, tags = withCapture()); + assertEquals(0, tags.length); + }}; + } + + @Test + public void testEmit() { + MetricRegistry metricRegistry = new MetricRegistry(); + Counter c = metricRegistry.counter("test"); + + Reporter reporter = (instance, timestamp, value, tags) -> { + assertEquals(c, instance); + assertEquals("test", instance.id); + assertEquals(1L, value); + assertEquals(0, tags.length); + }; + + metricRegistry.addReporter(reporter); + c.inc(); + } + + @Test + public void testEmitTags() { + MetricRegistry metricRegistry = new MetricRegistry(); + Counter c = metricRegistry.counter("test"); + + Reporter reporter = (instance, timestamp, value, tags) -> { + assertEquals(c, instance); + assertEquals("test", instance.id); + assertEquals(1L, value); + assertThat(tags, arrayContaining("TEST-key", "test-value")); + }; + + metricRegistry.addReporter(reporter); + c.inc("TEST-key", "test-value"); + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/GaugeDoubleTest.java b/core/src/test/java/io/ultrabrew/metrics/GaugeDoubleTest.java new file mode 100755 index 0000000..bdc3c91 --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/GaugeDoubleTest.java @@ -0,0 +1,45 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.arrayContaining; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import mockit.Expectations; +import mockit.Verifications; +import org.junit.jupiter.api.Test; + +public class GaugeDoubleTest { + + @Test + public void testGaugeDouble() { + + MetricRegistry metricRegistry = new MetricRegistry(); + GaugeDouble gaugeDouble = metricRegistry.gaugeDouble("cpuUsage"); + + new Expectations(gaugeDouble) {{ + gaugeDouble.emit(anyLong, (String[]) any); + }}; + + double v1 = 98.99; + gaugeDouble.set(v1); + + new Verifications() {{ + String[] tags; + gaugeDouble.emit(Double.doubleToRawLongBits(v1), tags = withCapture()); + assertEquals(0, tags.length); + }}; + + double v2 = -10.505; + gaugeDouble.set(v2, "TEST-key", "test-v1"); + + new Verifications() {{ + String[] tags; + gaugeDouble.emit(Double.doubleToRawLongBits(v2), tags = withCapture()); + assertThat(tags, arrayContaining("TEST-key", "test-v1")); + }}; + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/GaugeTest.java b/core/src/test/java/io/ultrabrew/metrics/GaugeTest.java new file mode 100755 index 0000000..4b5a13c --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/GaugeTest.java @@ -0,0 +1,42 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.arrayContaining; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import mockit.Expectations; +import mockit.Verifications; +import org.junit.jupiter.api.Test; + +public class GaugeTest { + + @Test + public void testGauge() { + MetricRegistry metricRegistry = new MetricRegistry(); + Gauge g = metricRegistry.gauge("test"); + + new Expectations(g) {{ + g.emit(anyLong, (String[]) any); + }}; + + g.set(100L); + + new Verifications() {{ + String[] tags; + g.emit(100L, tags = withCapture()); + assertEquals(0, tags.length); + }}; + + g.set(-101L, "TEST-key", "test-value"); + + new Verifications() {{ + String[] tags; + g.emit(-101L, tags = withCapture()); + assertThat(tags, arrayContaining("TEST-key", "test-value")); + }}; + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/JvmStatisticsCollectorTest.java b/core/src/test/java/io/ultrabrew/metrics/JvmStatisticsCollectorTest.java new file mode 100755 index 0000000..1aa7f32 --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/JvmStatisticsCollectorTest.java @@ -0,0 +1,64 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.ArrayList; +import java.util.List; +import mockit.Mocked; +import mockit.Verifications; +import org.junit.jupiter.api.Test; + +public class JvmStatisticsCollectorTest { + + @Test + public void testStartWhenStarted(@Mocked MetricRegistry registry) { + JvmStatisticsCollector collector = new JvmStatisticsCollector(registry); + + collector.start(100); + try { + collector.start(100); + fail("Expected exception"); + } catch (IllegalStateException e) { + collector.stop(); + } + } + + @Test + public void testStopWhenStopped(@Mocked MetricRegistry registry) { + JvmStatisticsCollector collector = new JvmStatisticsCollector(registry); + assertThrows(IllegalStateException.class, collector::stop); + } + + @Test + public void testCollecting(@Mocked Reporter reporter) throws InterruptedException { + MetricRegistry registry = new MetricRegistry(); + registry.addReporter(reporter); + JvmStatisticsCollector collector = new JvmStatisticsCollector(registry); + + collector.start(100); + Thread.sleep(100); + collector.stop(); + + new Verifications() {{ + List metrics = new ArrayList<>(); + reporter.emit(withCapture(metrics), anyLong, anyLong, (String[]) any); + + assertEquals("jvm.classloading.loaded", metrics.get(0).id); + assertEquals("jvm.classloading.unloaded", metrics.get(1).id); + assertEquals("jvm.classloading.totalLoaded", metrics.get(2).id); + assertEquals("jvm.compilation.totalTime", metrics.get(3).id); + assertEquals("jvm.thread.daemonCount", metrics.get(4).id); + assertEquals("jvm.thread.count", metrics.get(5).id); + assertEquals("jvm.thread.totalStartedCount", metrics.get(6).id); + + // There are some unverified items here, but they depend on the VM we're running on and are thus hard to verify + // For the same reason, we can't verify the number of calls to reporter.emit() as it depends on the VM + }}; + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/MetricRegistryTest.java b/core/src/test/java/io/ultrabrew/metrics/MetricRegistryTest.java new file mode 100755 index 0000000..c217d98 --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/MetricRegistryTest.java @@ -0,0 +1,140 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import mockit.Deencapsulation; +import org.junit.jupiter.api.Test; + +public class MetricRegistryTest { + + public static class TestKlass extends Metric { + + public TestKlass(final String id) { + super(null, id); + } + } + + @Test + public void testReturnSameInstance() { + MetricRegistry metricRegistry = new MetricRegistry(); + Counter c = metricRegistry.counter("test"); + assertEquals(c, metricRegistry.counter("test")); + } + + @Test + public void testAlreadyDefined() { + MetricRegistry metricRegistry = new MetricRegistry(); + metricRegistry.counter("test"); + assertThrows(IllegalStateException.class, () -> metricRegistry.timer("test")); + } + + @Test + public void testNoConstructor() { + MetricRegistry metricRegistry = new MetricRegistry(); + assertThrows(IllegalStateException.class, () -> metricRegistry.custom("test", TestKlass.class)); + } + + @Test + public void testAlreadyDefinedSynchronized() throws InterruptedException { + MetricRegistry metricRegistry = new MetricRegistry(); + final Map measurements = Deencapsulation + .getField(metricRegistry, "measurements"); + + final AtomicBoolean success = new AtomicBoolean(false); + final AtomicBoolean completed = new AtomicBoolean(false); + + synchronized (measurements) { + Thread t = new Thread(() -> { + try { + metricRegistry.counter("test"); + success.set(true); + } catch (IllegalStateException e) { + fail("No exception is thrown"); + } finally { + synchronized (completed) { + completed.set(true); + completed.notify(); + } + } + }); + t.start(); + + // wait until thread reaches the synchronized block. This test could be flaky if we don't wait long enough + while (t.getState() != Thread.State.BLOCKED) { + Thread.sleep(10); + } + + measurements.put("test", new Counter(metricRegistry, "test")); + } + + synchronized (completed) { + if (!completed.get()) { + completed.wait(10_000); + } + } + + if (!completed.get()) { + fail("Test thread did not complete!"); + } + + if (!success.get()) { + fail("Test thread succeeded in creating instance"); + } + } + + @Test + public void testAlreadyDefinedSynchronizedWrongType() throws InterruptedException { + MetricRegistry metricRegistry = new MetricRegistry(); + final Map measurements = Deencapsulation + .getField(metricRegistry, "measurements"); + + final AtomicBoolean success = new AtomicBoolean(false); + final AtomicBoolean completed = new AtomicBoolean(false); + + synchronized (measurements) { + Thread t = new Thread(() -> { + try { + metricRegistry.counter("test"); + fail("No exception is thrown"); + } catch (IllegalStateException e) { + success.set(true); + } finally { + synchronized (completed) { + completed.set(true); + completed.notify(); + } + } + }); + t.start(); + + // wait until thread reaches the synchronized block. This test could be flaky if we don't wait long enough + while (t.getState() != Thread.State.BLOCKED) { + Thread.sleep(10); + } + + measurements.put("test", new TestKlass("test")); + } + + synchronized (completed) { + if (!completed.get()) { + completed.wait(10_000); + } + } + + if (!completed.get()) { + fail("Test thread did not complete!"); + } + + if (!success.get()) { + fail("Test thread succeeded in creating instance"); + } + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/TimerTest.java b/core/src/test/java/io/ultrabrew/metrics/TimerTest.java new file mode 100755 index 0000000..4547695 --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/TimerTest.java @@ -0,0 +1,56 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.arrayContaining; +import static org.hamcrest.Matchers.greaterThan; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import mockit.Expectations; +import mockit.Verifications; +import org.junit.jupiter.api.Test; + +public class TimerTest { + + @Test + public void testTimer() { + MetricRegistry metricRegistry = new MetricRegistry(); + Timer t = metricRegistry.timer("test"); + + new Expectations(t) {{ + t.emit(anyLong, (String[]) any); + }}; + + final long startTime = t.start(); + t.stop(startTime); + + new Verifications() {{ + String[] tags; + long l; + t.emit(l = withCapture(), tags = withCapture()); + assertThat(l, greaterThan(0L)); + assertEquals(0, tags.length); + }}; + + t.stop(startTime, "TEST-key", "test-value"); + + new Verifications() {{ + String[] tags; + long l; + t.emit(l = withCapture(), tags = withCapture()); + assertThat(l, greaterThan(0L)); + assertThat(tags, arrayContaining("TEST-key", "test-value")); + }}; + + t.update(100L); + + new Verifications() {{ + String[] tags; + t.emit(100L, tags = withCapture()); + assertEquals(0, tags.length); + }}; + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/data/BasicCounterAggregatorTest.java b/core/src/test/java/io/ultrabrew/metrics/data/BasicCounterAggregatorTest.java new file mode 100755 index 0000000..cb4f028 --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/data/BasicCounterAggregatorTest.java @@ -0,0 +1,494 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import mockit.Deencapsulation; +import mockit.Expectations; +import org.junit.jupiter.api.Test; + +public class BasicCounterAggregatorTest { + + private long CURRENT_TIME = System.currentTimeMillis(); + + @Test + public void testAggregation() { + final BasicCounterAggregator table = new BasicCounterAggregator("test", 10); + + table.apply(new String[]{"testTag", "value"}, 100L, CURRENT_TIME); + table.apply(new String[]{"testTag", "value"}, 10L, CURRENT_TIME); + Cursor cursor = table.cursor(); + assertTrue(cursor.next()); + assertArrayEquals(new String[]{"sum"}, cursor.getFields()); + assertArrayEquals(new String[]{"testTag", "value"}, cursor.getTags()); + assertEquals(110L, cursor.readLong(0)); + assertEquals(1, table.size()); + + table.apply(new String[]{"testTag", "value", "testTag2", "value2"}, 10L, CURRENT_TIME); + table.apply(new String[]{"testTag", "value"}, 1L, CURRENT_TIME); + table.apply(new String[]{"testTag", "value", "testTag2", "value2"}, 99L, CURRENT_TIME); + assertEquals(2, table.size()); + + table.apply(new String[]{"testTag", "value2"}, 2L, CURRENT_TIME); + assertEquals(3, table.size()); + + String[] tagSet1 = new String[]{"testTag", "value"}; + String[] tagSet2 = new String[]{"testTag", "value", "testTag2", "value2"}; + String[] tagSet3 = new String[]{"testTag", "value2"}; + + assertEquals(10, table.capacity()); + assertEquals(3, table.size()); + + cursor = table.cursor(); + while (cursor.next()) { + final int hash = Arrays.hashCode(cursor.getTags()); + assertEquals(CURRENT_TIME, cursor.lastUpdated()); + if (hash == Arrays.hashCode(tagSet1)) { + assertEquals(111L, cursor.readLong(0)); + } else if (hash == Arrays.hashCode(tagSet2)) { + assertEquals(109L, cursor.readLong(0)); + } else if (hash == Arrays.hashCode(tagSet3)) { + assertEquals(2L, cursor.readLong(0)); + } else { + fail("Unknown hashcode"); + } + } + } + + @Test + public void testReadAndReset() { + final BasicCounterAggregator table = new BasicCounterAggregator("test", 10); + + table.apply(new String[]{"testTag", "value"}, 100L, CURRENT_TIME); + table.apply(new String[]{"testTag", "value"}, 10L, CURRENT_TIME); + Cursor cursor = table.cursor(); + assertTrue(cursor.next()); + + assertEquals(110L, cursor.readAndResetLong(0)); + + // Assert that identity is set + assertEquals(0L, cursor.readLong(0)); + } + + @Test + public void testGrowTable() { + final BasicCounterAggregator aggregator = new BasicCounterAggregator("test", 2); + aggregator.apply(new String[]{}, 1L, CURRENT_TIME); + aggregator.apply(new String[]{"testTag", "value"}, 1L, CURRENT_TIME); + aggregator.apply(new String[]{"testTag", "value2"}, 1L, CURRENT_TIME); + + assertEquals(3, aggregator.size()); + assertEquals(6, aggregator.capacity()); + // next prime from 2 is 3 + aggregator.apply(new String[]{"testTag", "value3"}, 1L, CURRENT_TIME); + + assertEquals(4, aggregator.size()); + assertEquals(6, aggregator.capacity()); + } + + @Test + public void testGrowTableWithMaxCapacity() { + final BasicCounterAggregator aggregator = new BasicCounterAggregator("test", 1, 3); + aggregator.apply(new String[]{}, 1L, CURRENT_TIME); + aggregator.apply(new String[]{"testTag", "value"}, 1L, CURRENT_TIME); + aggregator.apply(new String[]{"testTag", "value2"}, 1L, CURRENT_TIME); + + // Silently ignored, over capacity + aggregator.apply(new String[]{"testTag", "value3"}, 1L, CURRENT_TIME); + + assertEquals(3, aggregator.size()); + assertEquals(3, aggregator.capacity()); // caped at the max capacity. + } + + @Test + public void mathAbsReturnsNegative() { + final BasicCounterAggregator aggregator = new BasicCounterAggregator("test", 3); + Deencapsulation.setField(aggregator, "capacity", Integer.MAX_VALUE); + new Expectations(aggregator) {{ + aggregator.hashCode((String[]) any); + result = Integer.MIN_VALUE; + }}; + assertEquals(0, aggregator.index(new String[]{}, false)); + } + + @Test + public void growDataAndTagTableTillDefaultMaxLimit() { + + final int maxCapacity = 4096; + int capacity = maxCapacity - 1; + + final BasicCounterAggregator table = new BasicCounterAggregator("test", capacity); + String[][] tagSets = Deencapsulation.getField(table, "tagSets"); + + assertEquals(capacity, table.capacity()); + assertEquals(capacity, tagSets.length); + + for (int i = 0; i < maxCapacity + 2; i++) { + table.apply(new String[]{"testTag", String.valueOf(i)}, 1L, CURRENT_TIME); + } + + assertEquals(maxCapacity, table.size()); + assertEquals(maxCapacity, table.capacity()); + } + + @Test + public void growDataTable() { + final int requestedCapacity = 128 * 2; + final BasicCounterAggregator table = new BasicCounterAggregator("test", requestedCapacity); + + int capacity = table.capacity(); + assertEquals(requestedCapacity, capacity); + + for (int i = 0; i < capacity + 1; i++) { + table.apply(new String[]{"testTag", String.valueOf(i)}, 1L, CURRENT_TIME); + } + + assertEquals(capacity + 1, table.size()); + int newCapacity = capacity * 2 + capacity; + assertEquals(newCapacity, table.capacity()); + } + + @Test + public void growTagTable() { + final int requestedCapacity = 128; + final BasicCounterAggregator table = new BasicCounterAggregator("test", requestedCapacity); + + String[][] tagSets = Deencapsulation.getField(table, "tagSets"); + assertEquals(requestedCapacity, tagSets.length); + + for (int i = 0; i < requestedCapacity + 1; i++) { + table.apply(new String[]{"testTag", String.valueOf(i)}, 1L, CURRENT_TIME); + } + + int oldLength = tagSets.length; + tagSets = Deencapsulation.getField(table, "tagSets"); + assertEquals(oldLength * 2, tagSets.length); + } + + @Test + public void testTagsetsMaxSize() { + + final int maxCapacity = 4096; + final BasicCounterAggregator table = new BasicCounterAggregator("test", 128, maxCapacity); + + for (int i = 0; i < maxCapacity + 2; i++) { + table.apply(new String[]{"testTag", String.valueOf(i)}, 1L, CURRENT_TIME); + } + + assertEquals(maxCapacity, table.size()); + assertEquals(maxCapacity, table.capacity()); + } + + @Test + public void testTagsetsConcurrent() throws InterruptedException { + final BasicCounterAggregator table = new BasicCounterAggregator("test", 128); + final Object lock = new Object(); + synchronized (table) { + new Thread(() -> { + for (int i = 0; i < 64; i++) { + table.apply(new String[]{"testTag", String.valueOf(i)}, 1L, CURRENT_TIME); + } + synchronized (lock) { + lock.notify(); + } + for (int i = 64; i < 128; i++) { + table.apply(new String[]{"testTag", String.valueOf(i)}, 1L, CURRENT_TIME); + } + }).start(); + synchronized (lock) { + lock.wait(500L); + } + Thread.sleep(100L); + for (int i = 0; i < 128; i++) { + table.apply(new String[]{"testTag", String.valueOf(i)}, 1L, CURRENT_TIME); + } + } + assertEquals(128, table.size()); + } + + @Test + public void testReadLongInvalidFieldIndex() { + final BasicCounterAggregator table = new BasicCounterAggregator("test", 2); + table.apply(new String[]{}, 1L, CURRENT_TIME); + final Cursor cursor = table.cursor(); + assertTrue(cursor.next()); + assertThrows(IndexOutOfBoundsException.class, () -> cursor.readLong(cursor.getFields().length)); + } + + @Test + public void testReadLongInvalidCursorIndex() { + final BasicCounterAggregator table = new BasicCounterAggregator("test", 2); + table.apply(new String[]{}, 1L, CURRENT_TIME); + final Cursor cursor = table.cursor(); + assertThrows(IndexOutOfBoundsException.class, () -> cursor.readLong(0)); + } + + @Test + public void testReadAndResetLongInvalidFieldIndex() { + final BasicCounterAggregator table = new BasicCounterAggregator("test", 2); + table.apply(new String[]{}, 1L, CURRENT_TIME); + final Cursor cursor = table.cursor(); + assertTrue(cursor.next()); + assertThrows(IndexOutOfBoundsException.class, + () -> cursor.readAndResetLong(cursor.getFields().length)); + } + + @Test + public void testReadAndResetLongInvalidCursorIndex() { + final BasicCounterAggregator table = new BasicCounterAggregator("test", 2); + table.apply(new String[]{}, 1L, CURRENT_TIME); + final Cursor cursor = table.cursor(); + assertThrows(IndexOutOfBoundsException.class, () -> cursor.readAndResetLong(0)); + } + + @Test + public void testGetTagsInvalidCursorIndex() { + final BasicCounterAggregator table = new BasicCounterAggregator("test", 2); + table.apply(new String[]{}, 1L, CURRENT_TIME); + final Cursor cursor = table.cursor(); + assertThrows(IndexOutOfBoundsException.class, cursor::getTags); + } + + @Test + public void testLastUpdatedInvalidCursorIndex() { + final BasicCounterAggregator table = new BasicCounterAggregator("test", 2); + table.apply(new String[]{}, 1L, CURRENT_TIME); + final Cursor cursor = table.cursor(); + assertThrows(IndexOutOfBoundsException.class, cursor::lastUpdated); + } + + @Test + public void tableGrowthIsSynchronized() throws InterruptedException { + + final BasicCounterAggregator aggregator = new BasicCounterAggregator("test", 2); + aggregator.apply(new String[]{}, 1L, CURRENT_TIME); + aggregator.apply(new String[]{"testTag", "value"}, 1L, CURRENT_TIME); + + assertEquals(2, aggregator.size()); + assertEquals(2, aggregator.capacity()); + + Thread t1 = new Thread(() -> { + synchronized (aggregator) { + try { + Thread.sleep(100); + aggregator.index(new String[]{"testTag", "value2"}, false); + } catch (InterruptedException ignored) { + } + } + }, "t1"); + + Thread t2 = new Thread(() -> { + try { + Thread.sleep(50); + aggregator.index(new String[]{"testTag", "value3"}, false); + } catch (InterruptedException ignored) { + } + aggregator.index(new String[]{"testTag", "value3"}, false); + }, "t2"); + + t1.start(); + t2.start(); + + t1.join(); + t2.join(); + + assertEquals(4, aggregator.size()); + assertEquals(6, aggregator.capacity()); + } + + @Test + public void testConcurrentGrowTableWithMaxCapacity() throws InterruptedException { + + int requestedCapacity = 128; + int maxCapacity = requestedCapacity * 2; + final BasicCounterAggregator aggregator = new BasicCounterAggregator("test", requestedCapacity, maxCapacity); + + for (int i = 0; i < requestedCapacity; i++) { + aggregator.apply(new String[]{"testTag", String.valueOf(i)}, 1L, CURRENT_TIME); + } + + Thread t1 = new Thread(() -> { + synchronized (aggregator) { + try { + Thread.sleep(100); + aggregator.apply(new String[]{"testTag", String.valueOf(128)}, 1L, CURRENT_TIME); + } catch (InterruptedException ignored) { + } + } + }, "t1"); + + Thread t2 = new Thread(() -> { + aggregator.apply(new String[]{"testTag", String.valueOf(129)}, 1L, CURRENT_TIME); + }, "t2"); + + t1.start(); + Thread.sleep(5); + t2.start(); + + t1.join(); + t2.join(); + + assertEquals(130, aggregator.size()); + assertEquals(maxCapacity, aggregator.capacity()); // capped at the max capacity. + + for (int i = 130; i < maxCapacity + 2; i++) { + aggregator.apply(new String[]{"testTag", String.valueOf(i)}, 1L, CURRENT_TIME); + } + + assertEquals(maxCapacity, aggregator.size()); // silently dropped 2 data points after the max capacity + assertEquals(maxCapacity, aggregator.capacity()); // still capped at the max capacity. + } + + @Test + public void tagTableGrowthIsSynchronized() throws InterruptedException { + + int requestedCapacity = 131072; + final BasicCounterAggregator aggregator = new BasicCounterAggregator("test", requestedCapacity, 1_048_576); + + for (int i = 0; i < requestedCapacity * 2; i++) { + aggregator.apply(new String[]{"testTag", String.valueOf(i)}, 1L, CURRENT_TIME); + } + + String[][] tagSets = Deencapsulation.getField(aggregator, "tagSets"); + assertEquals(requestedCapacity * 2, tagSets.length); + + Thread t1 = new Thread(() -> { + synchronized (aggregator) { + try { + Thread.sleep(100); + aggregator.apply(new String[]{"testTag", String.valueOf(262145)}, 1L, CURRENT_TIME); + } catch (InterruptedException ignored) { + } + } + }, "t1"); + + Thread t2 = new Thread(() -> { + aggregator.apply(new String[]{"testTag", String.valueOf(262146)}, 1L, CURRENT_TIME); + }, "t2"); + + t1.start(); + Thread.sleep(5); + t2.start(); + + t1.join(); + t2.join(); + + int oldLength = tagSets.length; + tagSets = Deencapsulation.getField(aggregator, "tagSets"); + assertEquals(oldLength + requestedCapacity, tagSets.length); + assertEquals(262146, aggregator.size()); // ensures nothing is dropped. + } + + @Test + public void testConcurrentSlotReservation() throws InterruptedException { + + final BasicCounterAggregator table = new BasicCounterAggregator("test", 5); + + Thread t1 = new Thread(() -> { + + synchronized (table) { + try { + Thread.sleep(100); + table.index(new String[]{"testTag", "value1"}, false); + table.index(new String[]{"testTag", "value8"}, false); + } catch (InterruptedException ignored) { + } + } + }); + + Thread t2 = new Thread(() -> { + try { + Thread.sleep(50); + } catch (InterruptedException ignored) { + } + table.index(new String[]{"testTag", "value16"}, false); + table.index(new String[]{"testTag", "value23"}, false); + }); + + t1.start(); + t2.start(); + + t1.join(); + t2.join(); + + assertEquals(4, table.size()); + assertEquals(5, table.capacity()); + } + + @Test + public void createWithSizeZero() { + + final BasicCounterAggregator table = new BasicCounterAggregator("test", 0); + assertNotNull(table); + assertEquals(16, table.capacity()); + + table.apply(new String[]{}, 1L, CURRENT_TIME); + table.apply(new String[]{"testTag", "value"}, 1L, CURRENT_TIME); + + table.apply(new String[]{"testTag", "value2"}, 1L, CURRENT_TIME); + } + + @Test + public void createWithNegativeSize() { + assertThrows(IllegalArgumentException.class, () -> new BasicCounterAggregator("test", -1)); + } + + @Test + public void readsFromNextTableWhenCurrentSlotIsEmpty() { + + final BasicCounterAggregator table = new BasicCounterAggregator("test", 3); + String[] tagSet1 = new String[]{"key1", "value1"}; + String[] tagSet2 = new String[]{"key2", "value2"}; + String[] tagSet3 = new String[]{"key3", "value3"}; + String[] tagSet4 = new String[]{"key4", "value4"}; + String[] tagSet5 = new String[]{"key5", "value5"}; + + table.apply(tagSet1, 1, CURRENT_TIME); + table.apply(tagSet2, 1, CURRENT_TIME); + table.apply(tagSet3, 1, CURRENT_TIME); + table.apply(tagSet4, 1, CURRENT_TIME); + table.apply(tagSet5, 1, CURRENT_TIME); + + Cursor cursor = table.cursor(); + while (cursor.next()) { + assertEquals(1L, cursor.readLong(0)); + } + + List tables = Deencapsulation.getField(table, "tables"); + List recordCounts = Deencapsulation.getField(table, "recordCounts"); + List tableCapacities = Deencapsulation.getField(table, "tableCapacities"); + assertEquals(2, tables.size()); + assertEquals(2, recordCounts.size()); + assertEquals(3, recordCounts.get(0).get()); + assertEquals(2, recordCounts.get(1).get()); + assertEquals(2, tableCapacities.size()); + assertEquals(3, tableCapacities.get(0).intValue()); + assertEquals(6, tableCapacities.get(1).intValue()); + } + + @Test + public void readingByInvalidTagSets() { + final BasicCounterAggregator table = new BasicCounterAggregator("test", 3); + String[] tagSet1 = new String[]{"key1", "value1"}; + String[] tagSet2 = new String[]{"key2", "value2"}; + + table.apply(tagSet1, 1, CURRENT_TIME); + + Cursor cursor = table.cursor(); + Deencapsulation.setField(cursor, "tagSets", new String[][]{tagSet2}); + + assertFalse(cursor.next()); + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/data/BasicGaugeAggregatorTest.java b/core/src/test/java/io/ultrabrew/metrics/data/BasicGaugeAggregatorTest.java new file mode 100755 index 0000000..13e96c8 --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/data/BasicGaugeAggregatorTest.java @@ -0,0 +1,115 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +public class BasicGaugeAggregatorTest { + + private long CURRENT_TIME = System.currentTimeMillis(); + + @Test + public void testAggregation() { + final BasicGaugeAggregator table = new BasicGaugeAggregator("test", 10); + + table.apply(new String[]{"testTag", "value"}, 100L, CURRENT_TIME); + table.apply(new String[]{"testTag", "value"}, 10L, CURRENT_TIME); + Cursor cursor = table.cursor(); + assertTrue(cursor.next()); + assertArrayEquals(new String[]{"count", "sum", "min", "max", "lastValue"}, cursor.getFields()); + assertArrayEquals(new String[]{"testTag", "value"}, cursor.getTags()); + assertEquals(CURRENT_TIME, cursor.lastUpdated()); // last updated timestamp + assertEquals(2L, cursor.readLong(0)); // count + assertEquals(110L, cursor.readLong(1)); // sum + assertEquals(10L, cursor.readLong(2)); // min + assertEquals(100L, cursor.readLong(3)); // max + assertEquals(10L, cursor.readLong(4)); // lastValue + assertEquals(1, table.size()); + + table.apply(new String[]{"testTag", "value", "testTag2", "value2"}, 10L, CURRENT_TIME); + table.apply(new String[]{"testTag", "value"}, 1L, CURRENT_TIME); + table.apply(new String[]{"testTag", "value", "testTag2", "value2"}, 99L, CURRENT_TIME); + assertEquals(2, table.size()); + + table.apply(new String[]{"testTag", "value2"}, 2L, CURRENT_TIME); + assertEquals(3, table.size()); + + String[] tagSet1 = new String[]{"testTag", "value"}; + String[] tagSet2 = new String[]{"testTag", "value", "testTag2", "value2"}; + String[] tagSet3 = new String[]{"testTag", "value2"}; + + assertEquals(10, table.capacity()); + + cursor = table.cursor(); + while (cursor.next()) { + final int hash = Arrays.hashCode(cursor.getTags()); + assertEquals(CURRENT_TIME, cursor.lastUpdated()); // last updated timestamp + if (hash == Arrays.hashCode(tagSet1)) { + assertEquals(3L, cursor.readLong(0)); // count + assertEquals(111L, cursor.readLong(1)); // sum + assertEquals(1L, cursor.readLong(2)); // min + assertEquals(100L, cursor.readLong(3)); // max + assertEquals(1L, cursor.readLong(4)); // lastValue + } else if (hash == Arrays.hashCode(tagSet2)) { + assertEquals(2L, cursor.readLong(0)); // count + assertEquals(109L, cursor.readLong(1)); // sum + assertEquals(10L, cursor.readLong(2)); // min + assertEquals(99L, cursor.readLong(3)); // max + assertEquals(99L, cursor.readLong(4)); // lastValue + } else if (hash == Arrays.hashCode(tagSet3)) { + assertEquals(1L, cursor.readLong(0)); // count + assertEquals(2L, cursor.readLong(1)); // sum + assertEquals(2L, cursor.readLong(2)); // min + assertEquals(2L, cursor.readLong(3)); // max + assertEquals(2L, cursor.readLong(4)); // lastValue + } else { + fail("Unknown hashcode"); + } + } + } + + @Test + public void testReadAndReset() { + final BasicGaugeAggregator table = new BasicGaugeAggregator("test", 10); + + table.apply(new String[]{"testTag", "value"}, 100L, CURRENT_TIME); + table.apply(new String[]{"testTag", "value"}, 10L, CURRENT_TIME); + Cursor cursor = table.cursor(); + assertTrue(cursor.next()); + + assertEquals(2L, cursor.readAndResetLong(0)); // count + assertEquals(110L, cursor.readAndResetLong(1)); // sum + assertEquals(10L, cursor.readAndResetLong(2)); // min + assertEquals(100L, cursor.readAndResetLong(3)); // max + assertEquals(10L, cursor.readAndResetLong(4)); // lastValue + + // Assert that identity is set + assertEquals(0L, cursor.readLong(0)); // count + assertEquals(0L, cursor.readLong(1)); // sum + assertEquals(Long.MAX_VALUE, cursor.readLong(2)); // min + assertEquals(Long.MIN_VALUE, cursor.readLong(3)); // max + assertEquals(0L, cursor.readLong(4)); // lastValue + } + + @Test + public void testGrowTableWithMaxCapacity() { + final BasicGaugeAggregator aggregator = new BasicGaugeAggregator("test", 1, 3); + aggregator.apply(new String[]{}, 1L, CURRENT_TIME); + aggregator.apply(new String[]{"testTag", "value"}, 1L, CURRENT_TIME); + aggregator.apply(new String[]{"testTag", "value2"}, 1L, CURRENT_TIME); + + // Should be silently ignored, over capacity + aggregator.apply(new String[]{"testTag", "value3"}, 1L, CURRENT_TIME); + + assertEquals(3, aggregator.size()); + assertEquals(3, aggregator.capacity()); // caped at the max capacity. + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/data/BasicGaugeDoubleAggregatorTest.java b/core/src/test/java/io/ultrabrew/metrics/data/BasicGaugeDoubleAggregatorTest.java new file mode 100755 index 0000000..cca4111 --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/data/BasicGaugeDoubleAggregatorTest.java @@ -0,0 +1,154 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +public class BasicGaugeDoubleAggregatorTest { + + private long CURRENT_TIME = System.currentTimeMillis(); + + @Test + public void testAggregation() { + + double d1 = 10.19; + double d2 = 5179.0003; + double d3 = 59.5003; + double d4 = 100.3947; + + long l1 = Double.doubleToRawLongBits(d1); + long l2 = Double.doubleToRawLongBits(d2); + long l3 = Double.doubleToRawLongBits(d3); + long l4 = Double.doubleToRawLongBits(d4); + + final BasicGaugeDoubleAggregator aggregator = new BasicGaugeDoubleAggregator("test", 10); + + aggregator.apply(new String[]{"testTag", "value"}, l1, CURRENT_TIME); + aggregator.apply(new String[]{"testTag", "value"}, l2, CURRENT_TIME); + Cursor cursor = aggregator.cursor(); + assertTrue(cursor.next()); + assertArrayEquals(new String[]{"count", "sum", "min", "max", "lastValue"}, + cursor.getFields()); + assertArrayEquals(new String[]{"testTag", "value"}, cursor.getTags()); + assertEquals(CURRENT_TIME, cursor.lastUpdated()); // last updated timestamp + assertEquals(2L, cursor.readLong(0)); // count + assertEquals(d1 + d2, cursor.readDouble(1), 0.0001); // sum + assertEquals(d1, cursor.readDouble(2), 0.0001); // min + assertEquals(d2, cursor.readDouble(3), 0.0001); // max + assertEquals(d2, cursor.readDouble(4), 0.0001); // lastValue + assertEquals(1, aggregator.size()); + + aggregator.apply(new String[]{"testTag", "value", "testTag2", "value2"}, l2, CURRENT_TIME); + aggregator.apply(new String[]{"testTag", "value"}, l3, CURRENT_TIME); + aggregator.apply(new String[]{"testTag", "value", "testTag2", "value2"}, l4, CURRENT_TIME); + assertEquals(2, aggregator.size()); + + aggregator.apply(new String[]{"testTag", "value2"}, l1, CURRENT_TIME); + assertEquals(3, aggregator.size()); + + String[] tagSet1 = new String[]{"testTag", "value"}; + String[] tagSet2 = new String[]{"testTag", "value", "testTag2", "value2"}; + String[] tagSet3 = new String[]{"testTag", "value2"}; + + assertEquals(10, aggregator.capacity()); + + cursor = aggregator.cursor(); + while (cursor.next()) { + final int hash = Arrays.hashCode(cursor.getTags()); + assertEquals(CURRENT_TIME, cursor.lastUpdated()); // last updated timestamp + if (hash == Arrays.hashCode(tagSet1)) { + assertEquals(3L, cursor.readLong(0)); // count + assertEquals(d1 + d2 + d3, cursor.readDouble(1), 0.0001); // sum + assertEquals(d1, cursor.readDouble(2), 0.0001); // min + assertEquals(d2, cursor.readDouble(3), 0.0001); // max + assertEquals(d3, cursor.readDouble(4), 0.0001); // lastValue + } else if (hash == Arrays.hashCode(tagSet2)) { + assertEquals(2L, cursor.readLong(0)); // count + assertEquals(d2 + d4, cursor.readDouble(1), 0.0001); // sum + assertEquals(d4, cursor.readDouble(2), 0.0001); // min + assertEquals(d2, cursor.readDouble(3), 0.0001); // max + assertEquals(d4, cursor.readDouble(4), 0.0001); // lastValue + } else if (hash == Arrays.hashCode(tagSet3)) { + assertEquals(1L, cursor.readLong(0)); // count + assertEquals(d1, cursor.readDouble(1), 0.0001); // sum + assertEquals(d1, cursor.readDouble(2), 0.0001); // min + assertEquals(d1, cursor.readDouble(3), 0.0001); // max + assertEquals(d1, cursor.readDouble(4), 0.0001); // lastValue + } else { + fail("Unknown hashcode"); + } + } + } + + @Test + public void testReadAndReset() { + + double d1 = 5179.0003; + double d2 = 100.3947; + + long l1 = Double.doubleToRawLongBits(d1); + long l2 = Double.doubleToRawLongBits(d2); + + final BasicGaugeDoubleAggregator aggregator = new BasicGaugeDoubleAggregator("test"); + + aggregator.apply(new String[]{"testTag", "value"}, l1, CURRENT_TIME); + aggregator.apply(new String[]{"testTag", "value"}, l2, CURRENT_TIME); + Cursor cursor = aggregator.cursor(); + assertTrue(cursor.next()); + + assertEquals(2L, cursor.readAndResetLong(0)); // count + assertEquals(d1 + d2, cursor.readAndResetDouble(1), 0.0001); // sum + assertEquals(d2, cursor.readAndResetDouble(2), 0.0001); // min + assertEquals(d1, cursor.readAndResetDouble(3), 0.0001); // max + assertEquals(d2, cursor.readAndResetDouble(4), 0.0001); // lastValue + + // Assert that identity is set + assertEquals(0L, cursor.readLong(0)); // count + assertEquals(0L, cursor.readDouble(1), 0.0001); // sum + assertEquals(Double.NaN, cursor.readDouble(2), 0.0001); // min + assertEquals(-0.0, cursor.readDouble(3), 0.0001); // max + assertEquals(0.0, cursor.readDouble(4), 0.0001); // lastValue + } + + @Test + public void setLongValue() { + + double d = 10.19; + + long l = Double.doubleToRawLongBits(d); + + final BasicGaugeDoubleAggregator aggregator = new BasicGaugeDoubleAggregator("test"); + + aggregator.apply(new String[]{"testTag", "value"}, l, CURRENT_TIME); + Cursor cursor = aggregator.cursor(); + assertTrue(cursor.next()); + + assertEquals(1L, cursor.readLong(0)); // count + assertEquals(d, cursor.readAndResetDouble(1), 0.0001); // sum + assertEquals(d, cursor.readAndResetDouble(2), 0.0001); // min + assertEquals(d, cursor.readAndResetDouble(3), 0.0001); // max + assertEquals(d, cursor.readAndResetDouble(4), 0.0001); //lastValue + } + + @Test + public void testGrowTableWithMaxCapacity() { + final BasicGaugeDoubleAggregator aggregator = new BasicGaugeDoubleAggregator("test", 1, 3); + aggregator.apply(new String[]{}, 1L, CURRENT_TIME); + aggregator.apply(new String[]{"testTag", "value"}, 1L, CURRENT_TIME); + aggregator.apply(new String[]{"testTag", "value2"}, 1L, CURRENT_TIME); + + // Should be silently ignored, over capacity + aggregator.apply(new String[]{"testTag", "value3"}, 1L, CURRENT_TIME); + + assertEquals(3, aggregator.size()); + assertEquals(3, aggregator.capacity()); // caped at the max capacity. + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/data/BasicTimerAggregatorTest.java b/core/src/test/java/io/ultrabrew/metrics/data/BasicTimerAggregatorTest.java new file mode 100755 index 0000000..4a069ef --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/data/BasicTimerAggregatorTest.java @@ -0,0 +1,115 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +public class BasicTimerAggregatorTest { + + private long CURRENT_TIME = System.currentTimeMillis(); + + @Test + public void testAggregation() { + final BasicTimerAggregator table = new BasicTimerAggregator("test", 10); + + table.apply(new String[]{"testTag", "value"}, 100L, CURRENT_TIME); + table.apply(new String[]{"testTag", "value"}, 10L, CURRENT_TIME); + Cursor cursor = table.cursor(); + assertTrue(cursor.next()); + assertArrayEquals(new String[]{"count", "sum", "min", "max"}, cursor.getFields()); + assertArrayEquals(new String[]{"testTag", "value"}, cursor.getTags()); + assertEquals(CURRENT_TIME, cursor.lastUpdated()); // last updated timestamp + assertEquals(2L, cursor.readLong(0)); // count + assertEquals(110L, cursor.readLong(1)); // sum + assertEquals(10L, cursor.readLong(2)); // min + assertEquals(100L, cursor.readLong(3)); // max + assertEquals(1, table.size()); + + table.apply(new String[]{"testTag", "value", "testTag2", "value2"}, 10L, CURRENT_TIME); + table.apply(new String[]{"testTag", "value"}, 1L, CURRENT_TIME); + table.apply(new String[]{"testTag", "value", "testTag2", "value2"}, 99L, CURRENT_TIME); + assertEquals(2, table.size()); + + table.apply(new String[]{"testTag", "value2"}, 2L, CURRENT_TIME); + assertEquals(3, table.size()); + + String[] tagSet1 = new String[]{"testTag", "value"}; + String[] tagSet2 = new String[]{"testTag", "value", "testTag2", "value2"}; + String[] tagSet3 = new String[]{"testTag", "value2"}; + + assertEquals(10, table.capacity()); + + cursor = table.cursor(); + while (cursor.next()) { + final int hash = Arrays.hashCode(cursor.getTags()); + assertEquals(CURRENT_TIME, cursor.lastUpdated()); // last updated timestamp + if (hash == Arrays.hashCode(tagSet1)) { + assertEquals(3L, cursor.readLong(0)); // count + assertEquals(111L, cursor.readLong(1)); // sum + assertEquals(1L, cursor.readLong(2)); // min + assertEquals(100L, cursor.readLong(3)); // max + } else if (hash == Arrays.hashCode(tagSet2)) { + assertEquals(2L, cursor.readLong(0)); // count + assertEquals(109L, cursor.readLong(1)); // sum + assertEquals(10L, cursor.readLong(2)); // min + assertEquals(99L, cursor.readLong(3)); // max + } else if (hash == Arrays.hashCode(tagSet3)) { + assertEquals(1L, cursor.readLong(0)); // count + assertEquals(2L, cursor.readLong(1)); // sum + assertEquals(2L, cursor.readLong(2)); // min + assertEquals(2L, cursor.readLong(3)); // max + } else { + fail("Unknown hashcode"); + } + } + } + + @Test + public void testReadAndReset() { + final BasicTimerAggregator table = new BasicTimerAggregator("test"); + + table.apply(new String[]{"testTag", "value"}, 100L, CURRENT_TIME); + table.apply(new String[]{"testTag", "value"}, 10L, CURRENT_TIME); + Cursor cursor = table.cursor(); + assertTrue(cursor.next()); + + assertEquals(2L, cursor.readAndResetLong(0)); // count + assertEquals(110L, cursor.readAndResetLong(1)); // sum + assertEquals(10L, cursor.readAndResetLong(2)); // min + assertEquals(100L, cursor.readAndResetLong(3)); // max + + // Assert that identity is set + assertEquals(0L, cursor.readLong(0)); // count + assertEquals(0L, cursor.readLong(1)); // sum + assertEquals(Long.MAX_VALUE, cursor.readLong(2)); // min + assertEquals(Long.MIN_VALUE, cursor.readLong(3)); // max + } + + @Test + public void testInvalidMaxCapacity() { + assertThrows(IllegalArgumentException.class, () -> new BasicTimerAggregator("test", 10, 9)); + } + + @Test + public void testGrowTableWithMaxCapacity() { + final BasicTimerAggregator aggregator = new BasicTimerAggregator("test", 1, 3); + aggregator.apply(new String[]{}, 1L, CURRENT_TIME); + aggregator.apply(new String[]{"testTag", "value"}, 1L, CURRENT_TIME); + aggregator.apply(new String[]{"testTag", "value2"}, 1L, CURRENT_TIME); + + // Silently ignored, over capacity + aggregator.apply(new String[]{"testTag", "value3"}, 1L, CURRENT_TIME); + + assertEquals(3, aggregator.size()); + assertEquals(3, aggregator.capacity()); // caped at the max capacity. + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/data/ConcurrentMonoidHashTableTest.java b/core/src/test/java/io/ultrabrew/metrics/data/ConcurrentMonoidHashTableTest.java new file mode 100755 index 0000000..d55f8b5 --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/data/ConcurrentMonoidHashTableTest.java @@ -0,0 +1,27 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +public class ConcurrentMonoidHashTableTest { + + @Test + public void testNotMatchingFieldsAndIdentity() { + assertThrows(IllegalArgumentException.class, () -> new ConcurrentMonoidHashTable( + "test", + 128, + new String[]{"test"}, + new Type[]{Type.LONG}, + new long[]{0L, 1L}) { + @Override + protected void combine(long[] table, final long baseOffset, final long value) { + // no-op + } + }); + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/data/MultiCursorTest.java b/core/src/test/java/io/ultrabrew/metrics/data/MultiCursorTest.java new file mode 100755 index 0000000..6920abb --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/data/MultiCursorTest.java @@ -0,0 +1,82 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +import io.ultrabrew.metrics.reporters.AggregatingReporter; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MultiCursorTest { + + private static final Logger log = LoggerFactory.getLogger(MultiCursorTest.class); + + private long CURRENT_TIME = System.currentTimeMillis(); + + @Test + public void testMultiCursor() { + final BasicGaugeAggregator gaugeAggregator = new BasicGaugeAggregator("gauge", 10); + final BasicTimerAggregator timerAggregator = new BasicTimerAggregator("timer", 10); + final BasicCounterAggregator counterAggregator = new BasicCounterAggregator("counter", 10); + + final String[] tagSet1 = new String[]{"testTag", "value"}; + final String[] tagSet2 = new String[]{"testTag", "value", "testTag2", "value2"}; + // Test that duplicate string arrays are de-duped by multi-cursor + final String[] tagSet2b = new String[]{"testTag", "value", "testTag2", "value2"}; + final String[] tagSet3 = new String[]{"testTag", "value2"}; + final String[] tagSet4 = new String[]{"testTag3", "value"}; + + gaugeAggregator.apply(tagSet3, 1L, CURRENT_TIME); + gaugeAggregator.apply(tagSet3, 2L, CURRENT_TIME); + gaugeAggregator.apply(tagSet3, 3L, CURRENT_TIME); + gaugeAggregator.apply(tagSet2, 10L, CURRENT_TIME); + gaugeAggregator.apply(tagSet1, 0L, CURRENT_TIME); + gaugeAggregator.apply(tagSet4, -1L, CURRENT_TIME); + + timerAggregator.apply(tagSet2b, 110L, CURRENT_TIME); + timerAggregator.apply(tagSet2b, 100L, CURRENT_TIME); + + counterAggregator.apply(tagSet4, 1L, CURRENT_TIME); + counterAggregator.apply(tagSet1, 1L, CURRENT_TIME); + + final MultiCursor multiCursor = + new MultiCursor( + Arrays.asList(timerAggregator, gaugeAggregator, counterAggregator, + AggregatingReporter.NOOP)); + + final Map> records = new java.util.HashMap<>(); + + while (multiCursor.next()) { + CursorEntry entry = multiCursor.nextCursorEntry(); + while (entry != null) { + final List metricIds = records + .getOrDefault(multiCursor.getTags(), new java.util.ArrayList<>()); + metricIds.add(entry.getMetricId()); + records.put(multiCursor.getTags(), metricIds); + log.info("{} {}", multiCursor.getTags(), entry.getMetricId()); + + entry = multiCursor.nextCursorEntry(); + } + assertNull(multiCursor.nextCursorEntry()); + } + assertFalse(multiCursor.next()); + + assertEquals(4, records.size()); + assertThat(records.get(tagSet1), containsInAnyOrder("gauge", "counter")); + // Because timerAggregator is first in the list, its tagset2b is used for both + assertThat(records.get(tagSet2b), containsInAnyOrder("gauge", "timer")); + assertThat(records.get(tagSet3), containsInAnyOrder("gauge")); + assertThat(records.get(tagSet4), containsInAnyOrder("gauge", "counter")); + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/data/TagSetsHelperTest.java b/core/src/test/java/io/ultrabrew/metrics/data/TagSetsHelperTest.java new file mode 100755 index 0000000..dd7a106 --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/data/TagSetsHelperTest.java @@ -0,0 +1,75 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.lessThan; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class TagSetsHelperTest { + + @Test + public void testComparison() { + final String[] tagSet1 = new String[]{"testTag", "value"}; + final String[] tagSet2 = new String[]{"testTag", "value", "testTag2", "value2"}; + final String[] tagSet3 = new String[]{"testTag", "value2"}; + final String[] tagSet4 = new String[]{"testTag3", "value"}; + final String[] tagSet5 = new String[]{"testTag3", "value"}; + final String[] tagSet6 = null; + + assertEquals(0, TagSetsHelper.compare(tagSet1, tagSet1)); + assertEquals(0, TagSetsHelper.compare(tagSet2, tagSet2)); + assertEquals(0, TagSetsHelper.compare(tagSet3, tagSet3)); + assertEquals(0, TagSetsHelper.compare(tagSet4, tagSet4)); + assertEquals(0, TagSetsHelper.compare(tagSet4, tagSet5)); + assertEquals(0, TagSetsHelper.compare(tagSet6, tagSet6)); + + assertThat(TagSetsHelper.compare(tagSet1, tagSet2), lessThan(0)); + assertThat(TagSetsHelper.compare(tagSet1, tagSet3), lessThan(0)); + assertThat(TagSetsHelper.compare(tagSet1, tagSet4), lessThan(0)); + assertThat(TagSetsHelper.compare(tagSet2, tagSet3), lessThan(0)); + assertThat(TagSetsHelper.compare(tagSet2, tagSet4), lessThan(0)); + assertThat(TagSetsHelper.compare(tagSet3, tagSet4), lessThan(0)); + + assertThat(TagSetsHelper.compare(tagSet1, tagSet6), lessThan(0)); + assertThat(TagSetsHelper.compare(tagSet2, tagSet6), lessThan(0)); + assertThat(TagSetsHelper.compare(tagSet3, tagSet6), lessThan(0)); + assertThat(TagSetsHelper.compare(tagSet4, tagSet6), lessThan(0)); + + assertThat(TagSetsHelper.compare(tagSet5, tagSet3), greaterThan(0)); + assertThat(TagSetsHelper.compare(tagSet5, tagSet2), greaterThan(0)); + assertThat(TagSetsHelper.compare(tagSet5, tagSet1), greaterThan(0)); + assertThat(TagSetsHelper.compare(tagSet3, tagSet2), greaterThan(0)); + assertThat(TagSetsHelper.compare(tagSet3, tagSet1), greaterThan(0)); + assertThat(TagSetsHelper.compare(tagSet2, tagSet1), greaterThan(0)); + + assertThat(TagSetsHelper.compare(tagSet6, tagSet4), greaterThan(0)); + assertThat(TagSetsHelper.compare(tagSet6, tagSet3), greaterThan(0)); + assertThat(TagSetsHelper.compare(tagSet6, tagSet2), greaterThan(0)); + assertThat(TagSetsHelper.compare(tagSet6, tagSet1), greaterThan(0)); + } + + @Test + public void testNullComparison() { + final String[] tagSet1 = new String[]{"testTag", null}; + final String[] tagSet2 = new String[]{"testTag", "value"}; + final String[] tagSet3 = new String[]{null, "value"}; + + assertEquals(0, TagSetsHelper.compare(tagSet1, tagSet1)); + assertEquals(0, TagSetsHelper.compare(tagSet2, tagSet2)); + assertEquals(0, TagSetsHelper.compare(tagSet3, tagSet3)); + + assertThat(TagSetsHelper.compare(tagSet2, tagSet1), lessThan(0)); + assertThat(TagSetsHelper.compare(tagSet2, tagSet3), lessThan(0)); + assertThat(TagSetsHelper.compare(tagSet1, tagSet3), lessThan(0)); + + assertThat(TagSetsHelper.compare(tagSet1, tagSet2), greaterThan(0)); + assertThat(TagSetsHelper.compare(tagSet3, tagSet1), greaterThan(0)); + assertThat(TagSetsHelper.compare(tagSet3, tagSet2), greaterThan(0)); + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/data/TypeTest.java b/core/src/test/java/io/ultrabrew/metrics/data/TypeTest.java new file mode 100755 index 0000000..0680467 --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/data/TypeTest.java @@ -0,0 +1,50 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class TypeTest { + + private long CURRENT_TIME = System.currentTimeMillis(); + + @Test + public void readAndResetLong() { + + final BasicCounterAggregator table = new BasicCounterAggregator("test", 10); + + table.apply(new String[]{"testTag", "value"}, 100L, CURRENT_TIME); + table.apply(new String[]{"testTag", "value"}, 10L, CURRENT_TIME); + Cursor cursor = table.cursor(); + cursor.next(); + + Type[] types = cursor.getTypes(); + String s = types[0].readAndReset(cursor, 0); + assertEquals(String.valueOf(100L + 10L), s); + } + + @Test + public void readAndResetDouble() { + + double d1 = 10.19; + double d2 = 5179.0003; + + long l1 = Double.doubleToLongBits(d1); + long l2 = Double.doubleToLongBits(d2); + + final BasicGaugeDoubleAggregator table = new BasicGaugeDoubleAggregator("test"); + + table.apply(new String[]{"testTag", "value"}, l1, CURRENT_TIME); + table.apply(new String[]{"testTag", "value"}, l2, CURRENT_TIME); + Cursor cursor = table.cursor(); + cursor.next(); + + Type[] types = cursor.getTypes(); + assertEquals(String.valueOf(2), types[0].readAndReset(cursor, 0)); + assertEquals(String.valueOf(d2), types[1].readAndReset(cursor, 3)); + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/data/UnsafeHelperTest.java b/core/src/test/java/io/ultrabrew/metrics/data/UnsafeHelperTest.java new file mode 100755 index 0000000..fc826ae --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/data/UnsafeHelperTest.java @@ -0,0 +1,43 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.lang.reflect.Field; +import mockit.Expectations; +import org.junit.jupiter.api.Test; + +public class UnsafeHelperTest { + + private UnsafeHelper dummy; + + @Test + public void testIllegalAccessException() throws Exception { + assertNotNull(UnsafeHelper.unsafe); + + Field field = UnsafeHelperTest.class.getDeclaredField("dummy"); + new Expectations(Field.class) {{ + field.get(null); + result = new IllegalAccessException("test"); + }}; + try { + UnsafeHelper.getUnsafe(); + fail("No exception thrown"); + } catch (Throwable t) { + // Intellij gives different exception + if (t instanceof RuntimeException) { + assertTrue(t.getCause() instanceof IllegalAccessException); + assertEquals("test", t.getCause().getMessage()); + } else { + assertTrue(t.getCause().getCause() instanceof IllegalAccessException); + assertEquals("test", t.getCause().getCause().getMessage()); + } + } + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/reporters/BasicAggregatingReporterTest.java b/core/src/test/java/io/ultrabrew/metrics/reporters/BasicAggregatingReporterTest.java new file mode 100755 index 0000000..0ce7eb5 --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/reporters/BasicAggregatingReporterTest.java @@ -0,0 +1,83 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.reporters; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import io.ultrabrew.metrics.Counter; +import io.ultrabrew.metrics.Gauge; +import io.ultrabrew.metrics.GaugeDouble; +import io.ultrabrew.metrics.MetricRegistry; +import io.ultrabrew.metrics.data.Aggregator; +import io.ultrabrew.metrics.data.Cursor; +import io.ultrabrew.metrics.data.CursorEntry; +import io.ultrabrew.metrics.data.MultiCursor; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +public class BasicAggregatingReporterTest { + + @Test + public void testNoAggregatorForMetric() { + AggregatingReporter reporter = new AggregatingReporter(Collections.emptyMap()) { + }; + + MetricRegistry registry = new MetricRegistry(); + registry.addReporter(reporter); + Gauge gauge = registry.gauge("gauge"); + String[] tagSets = new String[]{"k1", "v1"}; + gauge.set(123, tagSets); + Aggregator aggregator = reporter.aggregators.values().iterator().next(); + assertFalse(aggregator.cursor().next()); + assertFalse(aggregator.sortedCursor().next()); + } + + @Test + public void testNullTags() { + AggregatingReporter reporter = new AggregatingReporter() { + }; + + MetricRegistry registry = new MetricRegistry(); + registry.addReporter(reporter); + Gauge gauge = registry.gauge("gauge"); + gauge.set(123, (String[]) null); + Aggregator aggregator = reporter.aggregators.values().iterator().next(); + Cursor cursor = aggregator.cursor(); + while (cursor.next()) { + assertArrayEquals(new String[]{}, cursor.getTags()); + } + } + + @Test + public void testReport() throws Exception { + AggregatingReporter reporter = new AggregatingReporter() { + }; + + MetricRegistry registry = new MetricRegistry(); + registry.addReporter(reporter); + String[] tagSets = new String[]{"k1", "v1"}; + String counterId = "persistentCounter"; + String gaugeId = "persistentGauge"; + Counter persistentCounter = registry.counter(counterId); + GaugeDouble persistentGauge = registry.gaugeDouble(gaugeId); + + persistentCounter.inc(tagSets); + persistentGauge.set(1.0, tagSets); + + MultiCursor multiCursor = new MultiCursor(reporter.aggregators.values()); + while (multiCursor.next()) { + CursorEntry cursorEntry = multiCursor.nextCursorEntry(); + String metricId = cursorEntry.getMetricId(); + if (metricId.equals(counterId)) { + assertEquals(1, cursorEntry.readLong(0)); + } else { + assertEquals(1, cursorEntry.readLong(0)); + assertEquals(1.0, cursorEntry.readDouble(1), 1.0); + } + } + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/reporters/SLF4JReporterTest.java b/core/src/test/java/io/ultrabrew/metrics/reporters/SLF4JReporterTest.java new file mode 100755 index 0000000..ced6976 --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/reporters/SLF4JReporterTest.java @@ -0,0 +1,267 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.reporters; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import io.ultrabrew.metrics.Counter; +import io.ultrabrew.metrics.Gauge; +import io.ultrabrew.metrics.GaugeDouble; +import io.ultrabrew.metrics.Metric; +import io.ultrabrew.metrics.MetricRegistry; +import io.ultrabrew.metrics.Timer; +import io.ultrabrew.metrics.data.Aggregator; +import io.ultrabrew.metrics.data.Cursor; +import java.util.ArrayList; +import java.util.List; +import mockit.Capturing; +import mockit.Deencapsulation; +import mockit.Expectations; +import mockit.Injectable; +import mockit.Verifications; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; + +public class SLF4JReporterTest { + + private SLF4JReporter reporter; + + @AfterEach + public void tearDown() { + if (reporter != null) { + reporter.stop(); + } + } + + public static class TestMetric extends Metric { + + public TestMetric(final MetricRegistry metricRegistry, final String name) { + super(metricRegistry, name); + } + + public void send(final long value, final String... tags) { + emit(value, tags); + } + } + + @Test + public void testReport(@Injectable Logger logger) throws InterruptedException { + + reporter = new SLF4JReporter("testReport", 1); + Deencapsulation.setField(reporter, "reporter", logger); + MetricRegistry metricRegistry = new MetricRegistry(); + metricRegistry.addReporter(reporter); + + long start = System.currentTimeMillis(); + + Counter counter = metricRegistry.counter("counter"); + counter.inc("testTag", "value"); + counter.inc("testTag", "value"); + counter.inc("testTag", "value2"); + counter.inc("testTag", "value", "testTag2", "value"); + counter.inc(); + + Timer timer = metricRegistry.timer("timer"); + timer.update(100L, "testTag", "value"); + timer.update(90L, "testTag", "value"); + timer.update(91L, "testTag", "value"); + timer.update(101L, "testTag", "value"); + timer.update(95L, "testTag", "value"); + timer.update(2000L, "testTag2", "value"); + timer.update(2020L, "testTag2", "value"); + timer.update(2010L, "testTag2", "value"); + + Gauge gauge = metricRegistry.gauge("gauge"); + gauge.set(12345L, "tag2", "100"); + gauge.set(12505L, "tag2", "100"); + gauge.set(10005L, "tag2", "100"); + gauge.set(105L, "tag2", "101"); + + GaugeDouble gaugeDouble = metricRegistry.gaugeDouble("gaugeDouble"); + gaugeDouble.set(329180.483740, "tag2", "100"); + gaugeDouble.set(102.87, "tag2", "100"); + gaugeDouble.set(87.1, "tag2", "100"); + gaugeDouble.set(83749.09098, "tag2", "101"); + + Thread.sleep(calculateDelay(1000, start) + 150); + + new Verifications() {{ + List objects = new ArrayList<>(); + logger.info("lastUpdated={} {}{}{} {}", withCapture(objects)); + + assertEquals(10, objects.size()); + + compare(objects.get(0), "tag2=100", "count=3 sum=34855 min=10005 max=12505 lastValue=10005", + "gauge"); + compare(objects.get(1), "tag2=101", "count=1 sum=105 min=105 max=105 lastValue=105", "gauge"); + + compare(objects.get(2), "testTag=value", "count=5 sum=477 min=90 max=101", "timer"); + compare(objects.get(3), "testTag2=value", "count=3 sum=6030 min=2000 max=2020", "timer"); + + compare(objects.get(4), "tag2=100", + "count=3 sum=329370.45373999997 min=87.1 max=329180.48374 lastValue=87.1", "gaugeDouble"); + compare(objects.get(5), "tag2=101", + "count=1 sum=83749.09098 min=83749.09098 max=83749.09098 lastValue=83749.09098", + "gaugeDouble"); + + compare(objects.get(6), "testTag=value", "sum=2", "counter"); + compare(objects.get(7), "testTag=value2", "sum=1", "counter"); + compare(objects.get(8), "testTag=value testTag2=value", "sum=1", "counter"); + compare(objects.get(9), "", "sum=1", "counter"); + }}; + } + + @Test + public void testUnknownMetric(@Injectable Logger logger) throws InterruptedException { + + reporter = new SLF4JReporter("testUnknownMetric"); + Deencapsulation.setField(reporter, "reporter", logger); + MetricRegistry metricRegistry = new MetricRegistry(); + metricRegistry.addReporter(reporter); + + TestMetric metric = metricRegistry.custom("custom", TestMetric.class); + + long start = System.currentTimeMillis(); + metric.send(10L, "tag", "value"); + Thread.sleep(calculateDelay(1000, start) + 150); + + new Verifications() {{ + logger.info(anyString, (Object[]) any); + times = 0; + }}; + } + + @Test + public void testNoFields(@Injectable Logger logger, @Capturing Cursor cursor) + throws InterruptedException { + + new Expectations() {{ + cursor.next(); + returns(true, false); + cursor.lastUpdated(); + returns(System.currentTimeMillis() + 2000L, 0L); + }}; + + reporter = new SLF4JReporter("testNoFields", 1); + Deencapsulation.setField(reporter, "reporter", logger); + MetricRegistry metricRegistry = new MetricRegistry(); + metricRegistry.addReporter(reporter); + Counter test = metricRegistry.counter("counter"); + + long start = System.currentTimeMillis(); + test.inc(); + Thread.sleep(calculateDelay(1000, start) + 150); + + new Verifications() {{ + List objects = new ArrayList<>(); + logger.info("lastUpdated={} {}{}{} {}", withCapture(objects)); + + assertEquals(1, objects.size()); + + compare(objects.get(0), "", "", "counter"); + }}; + } + + @Test + public void testUnchangedNotReported(@Injectable final Logger logger) + throws InterruptedException { + reporter = new SLF4JReporter("testNoInstrumentation", 1); + Deencapsulation.setField(reporter, "reporter", logger); + MetricRegistry metricRegistry = new MetricRegistry(); + metricRegistry.addReporter(reporter); + Counter counter = metricRegistry.counter("counter"); + + long start = System.currentTimeMillis(); + counter.inc(); + + // Sleep long enough that both buffers are reported twice + Thread.sleep(calculateDelay(1000, start) + 3150); + + new Verifications() {{ + // Only one report should occur since the counter has only changed once + logger.info("lastUpdated={} {}{}{} {}", (Object[]) any); + times = 1; + }}; + } + + @Test + public void testNullTags(@Injectable Logger logger) throws InterruptedException { + + reporter = new SLF4JReporter("testNullTags", 1); + Deencapsulation.setField(reporter, "reporter", logger); + MetricRegistry metricRegistry = new MetricRegistry(); + metricRegistry.addReporter(reporter); + + Counter test = metricRegistry.counter("counter"); + + long start = System.currentTimeMillis(); + reporter.emit(test, start, 1L, null); + Thread.sleep(calculateDelay(1000, start) + 150); + + new Verifications() {{ + logger.info("lastUpdated={} {}{}{} {}", start, "", " ", "sum=1", "counter"); + times = 1; + }}; + } + + @Test + public void testNOOP() { + Aggregator aggregator = AggregatingReporter.NOOP; + aggregator.apply(null, 0L, 1L); + assertFalse(aggregator.cursor().next()); + assertFalse(aggregator.sortedCursor().next()); + } + + @Test + public void testGaugeDouble(@Injectable Logger logger) throws InterruptedException { + + reporter = new SLF4JReporter("testGaugeDouble", 1); + Deencapsulation.setField(reporter, "reporter", logger); + MetricRegistry metricRegistry = new MetricRegistry(); + metricRegistry.addReporter(reporter); + + double d1 = 329180.483740; + long l2 = 102; + + long start = System.currentTimeMillis(); + GaugeDouble gaugeDouble = metricRegistry.gaugeDouble("gaugeDouble"); + gaugeDouble.set(d1, "tag2", "100"); + gaugeDouble.set(l2, "tag2", "101"); + + Thread.sleep(calculateDelay(1000, start) + 150); + + new Verifications() {{ + List objects = new ArrayList<>(); + logger.info("lastUpdated={} {}{}{} {}", withCapture(objects)); + + assertEquals(2, objects.size()); + + compare(objects.get(0), "tag2=100", + "count=1 sum=329180.48374 min=329180.48374 max=329180.48374 lastValue=329180.48374", + "gaugeDouble"); + compare(objects.get(1), "tag2=101", "count=1 sum=102.0 min=102.0 max=102.0 lastValue=102.0", + "gaugeDouble"); + }}; + } + + private void compare(final Object[] o, final String tags, final String fields, + final String metric) { + assertEquals(5, o.length); + assertNotNull(o[0]); + assertEquals(tags, o[1]); + assertEquals(" ", o[2]); // delimiter + assertEquals(fields, o[3]); + assertEquals(metric, o[4]); + + } + + public static long calculateDelay(long windowSizeMillis, long currentTimeMillis) { + long delay = windowSizeMillis - (currentTimeMillis % windowSizeMillis); + return delay + 10; + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/reporters/TimeWindowReporterTest.java b/core/src/test/java/io/ultrabrew/metrics/reporters/TimeWindowReporterTest.java new file mode 100755 index 0000000..34ba15b --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/reporters/TimeWindowReporterTest.java @@ -0,0 +1,455 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.reporters; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import io.ultrabrew.metrics.Counter; +import io.ultrabrew.metrics.MetricRegistry; +import io.ultrabrew.metrics.data.Aggregator; +import io.ultrabrew.metrics.data.CursorEntry; +import io.ultrabrew.metrics.data.MultiCursor; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import mockit.Deencapsulation; +import mockit.Expectations; +import mockit.Mocked; +import mockit.Verifications; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; + +public class TimeWindowReporterTest { + + private TimeWindowReporter reporter; + + private AggregatingReporter[] reporters; + + @BeforeEach + public void setUp() { + + reporter = new TimeWindowReporter("testReport") { + @Override + protected void doReport(Map aggregators) { + } + }; + + this.reporters = Deencapsulation.getField(reporter, "reporters"); + } + + @Test + public void writesToTheCurrentWindow() { + + MetricRegistry metricRegistry = new MetricRegistry(); + metricRegistry.addReporter(reporter); + + long currentTimeMillis = System.currentTimeMillis(); + int writerIndex = getWriterIndex(currentTimeMillis, TimeUnit.MINUTES.toMillis(1)); + + Counter counter = metricRegistry.counter("counter"); + String[] tagSet = {"testTag", "value"}; + counter.inc(tagSet); + counter.inc(); + + new Verifications() {{ + reporters[writerIndex].emit(counter, anyLong, 1, tagSet); + }}; + } + + @Test + public void reportsFromThePreviousWindow() throws Exception { + + MetricRegistry metricRegistry = new MetricRegistry(); + metricRegistry.addReporter(reporter); + + long currentTimeMillis = System.currentTimeMillis(); + long windowSizeMillis = TimeUnit.MINUTES.toMillis(1); + int writerIndex = getWriterIndex(currentTimeMillis, windowSizeMillis); + int readerIndex = getReaderIndex(writerIndex); + + Counter counter = metricRegistry.counter("counter"); + String[] tagSet = {"testTag", "value"}; + counter.inc(tagSet); + counter.inc(); + + String[] previousTagSet = {"testTag2", "value2"}; + long recordTime = currentTimeMillis - windowSizeMillis; + reporters[readerIndex].emit(counter, recordTime, 5, previousTagSet); + + new Expectations(reporter) { + }; + + reporter.report(); + + new Verifications() {{ + List> aggregatorsList = new ArrayList<>(); + reporter.doReport(withCapture(aggregatorsList)); + + assertEquals(1, aggregatorsList.size()); + Map aggregators = aggregatorsList.get(0); + MultiCursor multiCursor = new MultiCursor(aggregators.values()); + if (multiCursor.next()) { + String[] tags = multiCursor.getTags(); + assertArrayEquals(previousTagSet, tags); + + CursorEntry cursorEntry = multiCursor.nextCursorEntry(); + assertEquals("counter", cursorEntry.getMetricId()); + assertArrayEquals(previousTagSet, cursorEntry.getTags()); + assertEquals(recordTime, cursorEntry.lastUpdated()); + } else { + fail("Metrics not found"); + } + }}; + } + + @Test + public void defaultWindowSizeIs60Seconds() { + long windowSizeMillis = Deencapsulation.getField(reporter, "windowStepSizeMillis"); + assertEquals(TimeUnit.MINUTES.toMillis(1), windowSizeMillis); + } + + @Test + public void customWindowSize() { + + reporter = new TimeWindowReporter("testReport", 17) { + @Override + protected void doReport(Map aggregators) { + } + }; + + long windowSizeMillis = Deencapsulation.getField(reporter, "windowStepSizeMillis"); + assertEquals(TimeUnit.SECONDS.toMillis(17), windowSizeMillis); + } + + @Test + public void scheduledReportingByWindowSize() throws InterruptedException { + + reporter = new TimeWindowReporter("testReport", 1) { + @Override + protected void doReport(Map aggregators) { + } + }; + + new Expectations(reporter) {{ + }}; + + long start = System.currentTimeMillis(); + reporter.start(); + Thread.sleep(calculateDelay(1000, start) + 200); + + new Verifications() {{ + reporter.report(); + times = 1; + }}; + } + + @Test + public void stopReporting() throws Exception { + + reporter = new TimeWindowReporter("testReport", 1) { + @Override + protected void doReport(Map aggregators) { + } + }; + new Expectations(reporter) {{ + }}; + + long start = System.currentTimeMillis(); + + reporter.start(); + + Thread reportingThread = Deencapsulation.getField(reporter, "reportingThread"); + assertTrue(reportingThread.isAlive()); + + Thread.sleep(calculateDelay(1000, start)); + + reporter.close(); + + Thread.sleep(500); + + new Verifications() {{ + reporter.doReport(withInstanceOf(Map.class)); + times = 1; + }}; + assertFalse(reporter.isRunning()); + assertFalse(reportingThread.isAlive()); + } + + @Test + public void reportingThreadSleepsAgainIfInterruptedAndHasNotSleptEnough() + throws InterruptedException { + + long start = System.currentTimeMillis(); + reporter = new TimeWindowReporter("testReport", 1) { + @Override + protected void doReport(Map aggregators) { + } + }; + reporter.start(); + + new Expectations(reporter) {{ + }}; + + Thread reportingThread = Deencapsulation.getField(reporter, "reportingThread"); + + Thread.sleep(calculateDelay(1000, start) + 100); + + reportingThread.interrupt(); + + Thread.sleep(100); + + new Verifications() {{ + reporter.doReport(withInstanceOf(Map.class)); + times = 1; + }}; + } + + @Test + public void reportingThreadSleepsFor100ExtraMS() throws Exception { + + reporter = new TimeWindowReporter("testReport", 1) { + protected void doReport(Map aggregators) { + } + }; + + new Expectations(reporter) {{ + }}; + + long start = System.currentTimeMillis(); + reporter.start(); + Thread reportingThread = Deencapsulation.getField(reporter, "reportingThread"); + + Thread.sleep(calculateDelay(1000, start)); + + reportingThread.interrupt(); + + Thread.sleep(100); + + new Verifications() {{ + reporter.report(); + times = 0; + }}; + } + + @Test + public void reportingThreadDoesNotSleepsAgainIfInterruptedAndHasSleptEnough() + throws InterruptedException { + Semaphore sem = new Semaphore(0); + + reporter = new TimeWindowReporter("testReport", 1) { + protected void doReport(Map aggregators) { + sem.release(); + } + }; + + new Expectations(reporter) {{ + }}; + + long start = System.currentTimeMillis(); + reporter.start(); + Thread reportingThread = Deencapsulation.getField(reporter, "reportingThread"); + + // Wait until first report + sem.acquire(); + + // Ensure reportingThread is sleeping + Thread.sleep(500); + + // Force reportingThread to sleep longer than intended followed by being interrupted + // This is an ugly hack to avoid mocking System.currentTimeMillis() which does not work + reportingThread.suspend(); + Thread.sleep(2000); + reportingThread.interrupt(); + reportingThread.resume(); + + Thread.sleep(100); + + new Verifications() {{ + reporter.doReport(withInstanceLike(new ConcurrentHashMap<>())); + times = 2; + }}; + } + + @Test + public void logsErrorWhileReportingMetrics(@Mocked Logger logger) throws InterruptedException { + + RuntimeException exptected = new RuntimeException(); + + reporter = new TimeWindowReporter("testReport", 1) { + @Override + protected void doReport(Map multiCursor) { + throw exptected; + } + }; + Deencapsulation.setField(reporter, logger); + reporter.start(); + + Thread.sleep(1001); + + new Verifications() {{ + logger.error("Error reporting metrics", exptected); + }}; + } + + @Test + public void startSchedulerIsSynchronized() + throws InterruptedException { + + reporter = new TimeWindowReporter("testReport", 60) { + protected void doReport(Map aggregators) { + } + }; + + final boolean[] b1 = new boolean[1]; + final boolean[] b2 = new boolean[1]; + + Thread t1 = new Thread(() -> { + try { + reporter.start(); + b1[0] = true; + } catch (IllegalStateException ignored) { + b1[0] = false; + } + }); + + Thread t2 = new Thread(() -> { + try { + reporter.start(); + b2[0] = true; + } catch (IllegalStateException ignored) { + b2[0] = false; + } + }); + + t1.start(); + t2.start(); + + t1.join(); + t2.join(); + + assertTrue(b1[0] ^ b2[0]); + assertTrue(reporter.isRunning()); + } + + @Test + public void stopSchedulerIsSynchronized() throws InterruptedException { + + reporter = new TimeWindowReporter("testReport", 60) { + protected void doReport(Map aggregators) { + } + }; + + reporter.start(); + + AtomicInteger threadId = Deencapsulation.getField(reporter, "threadId"); + + assertEquals(1, threadId.get()); + + Thread t1 = new Thread(() -> { + synchronized (reporter) { + try { + Thread.sleep(100); + reporter.stop(); + } catch (InterruptedException ignored) { + } + } + }); + Thread t2 = new Thread(() -> { + reporter.stop(); + }); + + t1.start(); + Thread.sleep(5); + t2.start(); + + t1.join(); + t2.join(); + + reporter.stop(); + + assertFalse(reporter.isRunning()); + assertEquals(2, threadId.get()); + } + + @Test + public void testStopStartUnderHighConcurrency() throws InterruptedException { + reporter = new TimeWindowReporter("testReport", 1) { + protected void doReport(Map aggregators) { + } + }; + + final Thread[] rt1 = new Thread[1]; + final Thread[] rt2 = new Thread[1]; + + long now = System.currentTimeMillis(); + Thread t1 = new Thread(() -> { + try { + reporter.start(); + rt1[0] = Deencapsulation.getField(reporter, "reportingThread"); + Thread.sleep(calculateDelay(1000, now) + 100); // padding of 100 ms + } catch (InterruptedException ignored) { + } + }); + Thread t2 = new Thread(() -> { + try { + Thread.sleep(200); + reporter.stop(); + reporter.start(); + rt2[0] = Deencapsulation.getField(reporter, "reportingThread"); + } catch (InterruptedException ignored) { + } + }); + + t1.start(); + t2.start(); + t1.join(); + + // FIXME: This is broken, TimeWindowReporter.stop() does not wait for the thread to exit + assertFalse(rt1[0].isAlive()); + assertTrue(rt2[0].isAlive()); + } + + @Test + public void doesnotStartTheSchedulerByDefault() throws Exception { + assertFalse(reporter.isRunning()); + } + + @Test + public void doesnotStartSchedulerIfStartedAlready() { + reporter.start(); + assertThrows(IllegalStateException.class, () -> reporter.start()); + assertTrue(reporter.isRunning()); + } + + @Test + public void startScheduler() { + reporter.start(); + assertTrue(reporter.isRunning()); + } + + private int getWriterIndex(long milliseconds, long windowSizeMillis) { + return ((milliseconds / windowSizeMillis) & 1) == 0 ? 0 : 1; + } + + private int getReaderIndex(int writerIndex) { + return writerIndex == 0 ? 1 : 0; + } + + + public static long calculateDelay(long windowSizeMillis, long currentTimeMillis) { + long delay = windowSizeMillis - (currentTimeMillis % windowSizeMillis); + return delay + 10; + } +} diff --git a/core/src/test/java/io/ultrabrew/metrics/util/TagArrayTest.java b/core/src/test/java/io/ultrabrew/metrics/util/TagArrayTest.java new file mode 100755 index 0000000..be6603e --- /dev/null +++ b/core/src/test/java/io/ultrabrew/metrics/util/TagArrayTest.java @@ -0,0 +1,33 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.util; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import io.ultrabrew.metrics.util.TagArray.VariableKey; +import org.junit.jupiter.api.Test; + +public class TagArrayTest { + + @Test + public void testSorting() { + TagArray.Builder b = TagArray.builder(); + b.constant("z", "1"); + b.constant("a", "2"); + TagArray s = b.build(); + assertArrayEquals(new String[]{"a", "2", "z", "1"}, s.toArray()); + } + + @Test + public void testVariable() { + TagArray.Builder b = TagArray.builder(); + VariableKey v1 = b.variable("var1"); + VariableKey v2 = b.variable("var2"); + TagArray s = b.build(); + s.put(v1, "123"); + s.put(v2, "456"); + assertArrayEquals(new String[]{"var1", "123", "var2", "456"}, s.toArray()); + } +} diff --git a/core/src/test/resources/log4j2.xml b/core/src/test/resources/log4j2.xml new file mode 100755 index 0000000..673d7c6 --- /dev/null +++ b/core/src/test/resources/log4j2.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/undertow-httphandler/README.md b/examples/undertow-httphandler/README.md new file mode 100755 index 0000000..a3e8dfc --- /dev/null +++ b/examples/undertow-httphandler/README.md @@ -0,0 +1,31 @@ +# Example of using Ultrabrew Metrics in Undertow handlers + +This example demonstrates how the library can be used to gather metrics from applications using +embedded [Undertow](http://undertow.io) HTTP server. + +## How to run + +Run the application: +``` +$ ./gradlew :examples:undertow-httphandler:run +``` + +Access the application: + +``` +$ curl http://localhost:8080/hello +Hello World! +$ curl http://localhost:8080/hello +Hello World! +$ curl http://localhost:8080/foobar +$ curl http://localhost:8080/foobar +$ curl http://localhost:8080/foobar +``` + +Observe statistics in application console output: + +``` +14:04:30 [INFO ] [example] lastUpdated=1547471069043 method=GET handler=DEFAULT status=404 count=3 sum=565833 min=139596 max=217509 http.request +14:04:30 [INFO ] [example] lastUpdated=1547471067061 method=GET handler=HelloWorldHandler status=200 count=2 sum=2483431 min=370978 max=2112453 http.request +14:04:30 [INFO ] [example] lastUpdated=1547471067061 sum=2 hello +``` \ No newline at end of file diff --git a/examples/undertow-httphandler/build.gradle b/examples/undertow-httphandler/build.gradle new file mode 100755 index 0000000..ccdd3f0 --- /dev/null +++ b/examples/undertow-httphandler/build.gradle @@ -0,0 +1,17 @@ +plugins { + id 'application' +} + +mainClassName = 'io.ultrabrew.metrics.examples.ExampleServer' + +publishMavenJavaPublicationToMavenRepository { + enabled = false +} + +dependencies { + compile project(':core') + + compile group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.11.1' + compile group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.11.1' + compile group: 'io.undertow', name: 'undertow-core', version: '2.0.16.Final' +} diff --git a/examples/undertow-httphandler/src/main/java/io/ultrabrew/metrics/examples/ExampleServer.java b/examples/undertow-httphandler/src/main/java/io/ultrabrew/metrics/examples/ExampleServer.java new file mode 100755 index 0000000..bed2365 --- /dev/null +++ b/examples/undertow-httphandler/src/main/java/io/ultrabrew/metrics/examples/ExampleServer.java @@ -0,0 +1,42 @@ +package io.ultrabrew.metrics.examples; + +import io.ultrabrew.metrics.MetricRegistry; +import io.ultrabrew.metrics.Reporter; +import io.ultrabrew.metrics.examples.handlers.HelloWorldHandler; +import io.ultrabrew.metrics.examples.handlers.MetricsHandler; +import io.ultrabrew.metrics.reporters.SLF4JReporter; +import io.undertow.Undertow; +import io.undertow.predicate.Predicates; +import io.undertow.server.HttpHandler; +import io.undertow.server.handlers.PathHandler; +import io.undertow.server.handlers.PredicateHandler; +import io.undertow.server.handlers.ResponseCodeHandler; +import io.undertow.server.handlers.error.SimpleErrorPageHandler; + +public class ExampleServer { + + public static final MetricRegistry metricRegistry = new MetricRegistry(); + + public static void main(String... args) { + // Create a reporter and add it to the registry. + // For demo purposes we use SLF4JReporter which should not be used for production systems + Reporter reporter = new SLF4JReporter("example", 10); + metricRegistry.addReporter(reporter); + + // Create the handler chain + // MetricsHandler -> PathHandler -> HelloWorldHandler / 404 + HttpHandler helloHandler = new HelloWorldHandler(); + PathHandler pathHandler = new PathHandler(); + pathHandler.addExactPath("/hello", helloHandler); + HttpHandler metricsHandler = new MetricsHandler(pathHandler); + + // Build the server instance + Undertow server = Undertow.builder() + .addHttpListener(8080, "localhost") + .setHandler(metricsHandler) + .build(); + + // And start it + server.start(); + } +} diff --git a/examples/undertow-httphandler/src/main/java/io/ultrabrew/metrics/examples/handlers/HelloWorldHandler.java b/examples/undertow-httphandler/src/main/java/io/ultrabrew/metrics/examples/handlers/HelloWorldHandler.java new file mode 100755 index 0000000..0feae1b --- /dev/null +++ b/examples/undertow-httphandler/src/main/java/io/ultrabrew/metrics/examples/handlers/HelloWorldHandler.java @@ -0,0 +1,28 @@ +package io.ultrabrew.metrics.examples.handlers; + +import io.ultrabrew.metrics.Counter; +import io.ultrabrew.metrics.examples.ExampleServer; +import io.undertow.server.HttpHandler; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.Headers; + +/** + * Simple handler that emits some metrics and sends a plain text response to client. + */ +public class HelloWorldHandler implements HttpHandler { + + private final Counter helloCount = ExampleServer.metricRegistry.counter("hello"); + + @Override + public void handleRequest(HttpServerExchange exchange) throws Exception { + // Store the name of the handler to the HttpServerExchange so MetricsHandler can use it later + exchange.putAttachment(MetricsHandler.REQUEST_HANDLER_KEY, getClass().getSimpleName()); + + // Emit some metrics specific to this handler + helloCount.inc(); + + // Send response to client + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/plain"); + exchange.getResponseSender().send("Hello World!\n"); + } +} diff --git a/examples/undertow-httphandler/src/main/java/io/ultrabrew/metrics/examples/handlers/MetricsHandler.java b/examples/undertow-httphandler/src/main/java/io/ultrabrew/metrics/examples/handlers/MetricsHandler.java new file mode 100755 index 0000000..69037bd --- /dev/null +++ b/examples/undertow-httphandler/src/main/java/io/ultrabrew/metrics/examples/handlers/MetricsHandler.java @@ -0,0 +1,61 @@ +package io.ultrabrew.metrics.examples.handlers; + +import io.ultrabrew.metrics.Timer; +import io.ultrabrew.metrics.examples.ExampleServer; +import io.undertow.server.HttpHandler; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.AttachmentKey; + +/** + * An example {@link HttpHandler} that collects request metrics. + */ +public class MetricsHandler implements HttpHandler { + + // The key under which we expect the final handler to have attached its name + public static final AttachmentKey REQUEST_HANDLER_KEY = AttachmentKey + .create(String.class); + + // Tags used in emitted metrics + private static final String REQUEST_METHOD = "method"; + private static final String REQUEST_HANDLER = "handler"; + private static final String STATUS_CODE = "status"; + + // String used if handler name is missing + private static final String NO_HANDLER = "DEFAULT"; + + private final Timer requestTimer = ExampleServer.metricRegistry.timer("http.request"); + + private final HttpHandler next; + + public MetricsHandler(HttpHandler next) { + this.next = next; + } + + @Override + public void handleRequest(HttpServerExchange exchange) throws Exception { + // Start counting request handling time + long start = requestTimer.start(); + + // Attach a listener to request completion event + exchange.addExchangeCompleteListener((ex, nxt) -> { + + // Get the name of the final handler or our default string if it does not exist + String handler = ex.getAttachment(REQUEST_HANDLER_KEY); + if (handler == null) { + handler = NO_HANDLER; + } + + // Emit the duration of request processing, implicitly also produces request count + requestTimer.stop(start, + REQUEST_METHOD, ex.getRequestMethod().toString(), + REQUEST_HANDLER, handler, + STATUS_CODE, Integer.toString(ex.getStatusCode())); + + // Chain to next listener + nxt.proceed(); + }); + + // Let the next handler process the request + next.handleRequest(exchange); + } +} diff --git a/examples/undertow-httphandler/src/main/resources/log4j2.xml b/examples/undertow-httphandler/src/main/resources/log4j2.xml new file mode 100755 index 0000000..673d7c6 --- /dev/null +++ b/examples/undertow-httphandler/src/main/resources/log4j2.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/webapp/README.md b/examples/webapp/README.md new file mode 100755 index 0000000..eed70ff --- /dev/null +++ b/examples/webapp/README.md @@ -0,0 +1,49 @@ +# Example webapp using Ultrabrew Metrics + +This is a simple webapp that demonstrates how Ultrabrew Metrics can be used to gather metrics from +webapps. To run a Servlet 3.0 compatible application server such as +[Jetty](https://www.eclipse.org/jetty/) or [WildFly](http://wildfly.org/) is required. + +The application provides one servlet that simulates slow processing and a filter that provides data +about request processing time. The metrics are aggregated over 10 second period and printed to +stderr. + +## How to run + +The example can be built into a WAR by running `./gradlew :examples:webapp:war` from the project top +level directory. The resulting archive will be named `examples/webapp/build/libs/metrics.war`. + +The exact way to deploy the applications as WARs depends on the application server, please see +its documentation for details. + +### Run on Jetty + +Assuming you have built the WAR archive as detailed above and have downloaded and extracted Jetty, +in the extracted Jetty directory: + +1. Deploy the WAR: `cp $METRICS_ROOT/examples/webapp/build/libs/metrics.war webapps/` +1. Start Jetty: `java -jar start.jar` +1. Run some requests against the demo servlet: `curl http://localhost:8080/metrics/slow` + +You should be able to observe some metrics printed to the Jetty console, for example: + +``` +15:07:30 [INFO ] [example] lastUpdated=1547215648555 count=3 sum=7317174587 min=1826107576 max=2922074643 MyApp.Servlet.requestDuration +15:07:40 [INFO ] [example] lastUpdated=1547215653763 count=1 sum=2154418145 min=2154418145 max=2154418145 MyApp.Servlet.requestDuration +``` + +### Run on WildFly + +Assuming you have built the WAR archive as detailed above and have downloaded and extracted WildFly, +in the extracted WildFly directory: + +1. Deploy the WAR: `cp ~/metrics/examples/webapp/build/libs/metrics.war standalone/deployments/` +1. Start WildFly: `bin/standalone.sh` +1. Run some requests against the demo servlet: `curl http://localhost:8080/metrics/slow` + +You should be able to observe some metrics printed to the WildFly console, for example: + +``` +15:11:40,105 INFO [example] (example-1) lastUpdated=1547215897655 count=1 sum=2298432552 min=2298432552 max=2298432552 MyApp.Servlet.requestDuration +15:11:50,104 INFO [example] (example-1) lastUpdated=1547215907333 count=3 sum=7955554341 min=2468268241 max=2970018942 MyApp.Servlet.requestDuration +``` diff --git a/examples/webapp/build.gradle b/examples/webapp/build.gradle new file mode 100755 index 0000000..081b782 --- /dev/null +++ b/examples/webapp/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'war' +} + +publishMavenJavaPublicationToMavenRepository { + enabled = false +} + +dependencies { + compile project(':core') + + compile group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.11.1' + compile group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.11.1' + + providedCompile group: 'javax.servlet', name: 'javax.servlet-api', version: '3.1.0' +} + +war { + archiveName = 'metrics.war' +} \ No newline at end of file diff --git a/examples/webapp/src/main/java/io/ultrabrew/metrics/examples/MyApp.java b/examples/webapp/src/main/java/io/ultrabrew/metrics/examples/MyApp.java new file mode 100755 index 0000000..a96743c --- /dev/null +++ b/examples/webapp/src/main/java/io/ultrabrew/metrics/examples/MyApp.java @@ -0,0 +1,8 @@ +package io.ultrabrew.metrics.examples; + +import io.ultrabrew.metrics.MetricRegistry; + +public class MyApp { + + public static final MetricRegistry metricRegistry = new MetricRegistry(); +} diff --git a/examples/webapp/src/main/java/io/ultrabrew/metrics/examples/filters/RequestMetricsFilter.java b/examples/webapp/src/main/java/io/ultrabrew/metrics/examples/filters/RequestMetricsFilter.java new file mode 100755 index 0000000..afb4307 --- /dev/null +++ b/examples/webapp/src/main/java/io/ultrabrew/metrics/examples/filters/RequestMetricsFilter.java @@ -0,0 +1,44 @@ +package io.ultrabrew.metrics.examples.filters; + +import io.ultrabrew.metrics.Timer; +import io.ultrabrew.metrics.examples.MyApp; +import java.io.IOException; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.annotation.WebFilter; + +/** + * A filter that gathers metrics about servlet requests. + */ +@WebFilter(urlPatterns = {"/*"}) +public class RequestMetricsFilter implements Filter { + + // A timer metric to gather statistics about request durations, note that a separate counter is + // not necessary as the timer also collects count + private final Timer requestTimer = MyApp.metricRegistry.timer("MyApp.Servlet.requestDuration"); + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + // Note when request handling starts + long start = requestTimer.start(); + // Chain to next filter, will eventually run the actual servlet + // Note: This is not sufficient to properly handle async requests + chain.doFilter(request, response); + // TODO: Add some useful tags + // Update our timer metrics with the request duration + requestTimer.stop(start); + } + + @Override + public void destroy() { + } +} diff --git a/examples/webapp/src/main/java/io/ultrabrew/metrics/examples/listeners/MetricsInitializer.java b/examples/webapp/src/main/java/io/ultrabrew/metrics/examples/listeners/MetricsInitializer.java new file mode 100755 index 0000000..ab79913 --- /dev/null +++ b/examples/webapp/src/main/java/io/ultrabrew/metrics/examples/listeners/MetricsInitializer.java @@ -0,0 +1,33 @@ +package io.ultrabrew.metrics.examples.listeners; + +import io.ultrabrew.metrics.examples.MyApp; +import io.ultrabrew.metrics.reporters.SLF4JReporter; +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; +import javax.servlet.annotation.WebListener; + +/** + * A context lifecycle listener, used to initialize and shutdown metrics reporting. + */ +@WebListener +public class MetricsInitializer implements ServletContextListener { + + private static final String REPORTER_ATTRIBUTE = "io.ultrabrew.metrics.examples.reporter"; + + @Override + public void contextInitialized(ServletContextEvent sce) { + // Create a reporter and add it to the registry. + // For demo purposes we use SLF4JReporter which should not be used for production systems + SLF4JReporter reporter = new SLF4JReporter("example", 10); + MyApp.metricRegistry.addReporter(reporter); + sce.getServletContext().setAttribute(REPORTER_ATTRIBUTE, reporter); + } + + @Override + public void contextDestroyed(ServletContextEvent sce) { + // Clean up the reporter here to avoid leaking resources + SLF4JReporter reporter = (SLF4JReporter) sce.getServletContext() + .getAttribute(REPORTER_ATTRIBUTE); + reporter.close(); + } +} diff --git a/examples/webapp/src/main/java/io/ultrabrew/metrics/examples/servlets/SlowServlet.java b/examples/webapp/src/main/java/io/ultrabrew/metrics/examples/servlets/SlowServlet.java new file mode 100755 index 0000000..1b3eb7a --- /dev/null +++ b/examples/webapp/src/main/java/io/ultrabrew/metrics/examples/servlets/SlowServlet.java @@ -0,0 +1,32 @@ +package io.ultrabrew.metrics.examples.servlets; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.io.IOException; +import java.util.concurrent.ThreadLocalRandom; +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * A trivial servlet that simulates some slow processing by sleeping a random time between 1-3 + * seconds. + */ +@WebServlet(urlPatterns = "/slow") +@SuppressFBWarnings(value = "SECPR", justification = "Not production code") +public class SlowServlet extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + try { + Thread.sleep(ThreadLocalRandom.current().nextLong(1000, 3000)); + } catch (InterruptedException e) { + // Shouldn't happen, don't care if it does + } + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("text/plain"); + resp.getWriter().println("Slow process done."); + } +} diff --git a/examples/webapp/src/main/resources/log4j2.xml b/examples/webapp/src/main/resources/log4j2.xml new file mode 100755 index 0000000..673d7c6 --- /dev/null +++ b/examples/webapp/src/main/resources/log4j2.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100755 index 0000000000000000000000000000000000000000..0d4a9516871afd710a9d84d89e31ba77745607bd GIT binary patch literal 54413 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100755 index 0000000..558870d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## 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="" + +# 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 + ;; + 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, switch paths to Windows format before running java +if $cygwin ; 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=$((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" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100755 index 0000000..e95643d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@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 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= + +@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/reporter-influxdb/build.gradle b/reporter-influxdb/build.gradle new file mode 100755 index 0000000..70149ad --- /dev/null +++ b/reporter-influxdb/build.gradle @@ -0,0 +1,4 @@ +dependencies { + compile project(':core') + compile group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.5.6' +} diff --git a/reporter-influxdb/src/main/java/io/ultrabrew/metrics/reporters/influxdb/InfluxDBClient.java b/reporter-influxdb/src/main/java/io/ultrabrew/metrics/reporters/influxdb/InfluxDBClient.java new file mode 100755 index 0000000..5487a45 --- /dev/null +++ b/reporter-influxdb/src/main/java/io/ultrabrew/metrics/reporters/influxdb/InfluxDBClient.java @@ -0,0 +1,131 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.reporters.influxdb; + +import java.io.IOException; +import java.net.URI; +import java.nio.BufferOverflowException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ByteArrayEntity; +import org.apache.http.entity.ContentType; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; + +/** + * This class provides methods to access InfluxDB. + */ +public class InfluxDBClient { + + private static final String UTF_8 = StandardCharsets.UTF_8.name(); + private static final byte WHITESPACE = ' '; + private static final byte COMMA = ','; + private static final byte EQUALS = '='; + private static final byte NEWLINE = '\n'; + + private final ByteBuffer byteBuffer; + private final URI dbUri; + private final CloseableHttpClient httpClient; + + InfluxDBClient(final URI dbUri, final int bufferSize) { + this.httpClient = getHttpClient(); + this.byteBuffer = ByteBuffer.allocate(bufferSize); + this.dbUri = dbUri; + } + + private CloseableHttpClient getHttpClient() { + RequestConfig httpRequestConfig = RequestConfig.custom() + .setConnectTimeout(3000) + .setSocketTimeout(3000) + .setConnectionRequestTimeout(3000) + .build(); + + return HttpClients.custom() + .disableCookieManagement() + .setMaxConnPerRoute(2 * 3) + .setMaxConnTotal(2 * 3) + .setDefaultRequestConfig(httpRequestConfig) + .build(); + } + + private void doWrite(final String measurement, final String[] tags, final String[] fields, + final long timestamp) + throws IOException { + byteBuffer.put(measurement.getBytes(UTF_8)); + for (int i = 0; i < tags.length; i += 2) { + byteBuffer.put(COMMA) + .put(tags[i].getBytes(UTF_8)) + .put(EQUALS) + .put(tags[i + 1].getBytes(UTF_8)); + } + byteBuffer.put(WHITESPACE); + + boolean f = true; + for (int i = 0; i < fields.length; i += 2) { + if (!f) { + byteBuffer.put(COMMA); + } + byteBuffer.put(fields[i].getBytes(UTF_8)) + .put(EQUALS) + .put(fields[i + 1].getBytes(UTF_8)); + f = false; + } + if (timestamp > 0) { + byteBuffer.put(WHITESPACE) + .put(Long.toString(timestamp).getBytes(UTF_8)); + } + byteBuffer.put(NEWLINE); + } + + public void write(final String measurement, final String[] tags, final String[] fields, + final long timestamp) + throws IOException { + // CLOVER:OFF + // The loop exit condition is supposed to be unreachable + for (int retry = 0; retry < 2; retry++) { + // CLOVER:ON + byteBuffer.mark(); + try { + doWrite(measurement, tags, fields, timestamp); + return; + } catch (BufferOverflowException e) { + byteBuffer.reset(); + if (byteBuffer.position() == 0) { + throw new IllegalArgumentException("Internal buffer too small to fit one measurement"); + } else { + flush(); + } + } + } + // CLOVER:OFF + // This should be truly unreachable + throw new RuntimeException("Internal error"); + // CLOVER:ON + } + + public void flush() throws IOException { + if (byteBuffer.position() == 0) { + return; + } + byteBuffer.flip(); + HttpPost httpPost = new HttpPost(this.dbUri); + httpPost.setEntity(new ByteArrayEntity(byteBuffer.array(), ContentType.DEFAULT_TEXT)); + CloseableHttpResponse response = httpClient.execute(httpPost); + int statusCode = response.getStatusLine().getStatusCode(); + // Always clear the buffer. But this will lead to data loss in case of non 2xx response (i.e write operation failed) + // received from the InfluxDB server. Ideally non 2xx server response should be rare but revisit this part + // if data loss occurs frequently. + byteBuffer.clear(); + if (statusCode / 100 != 2) { + throw new IOException("InfluxDB write failed: " + statusCode + " " + response.getStatusLine() + .getReasonPhrase()); + } + EntityUtils.consumeQuietly(response.getEntity()); + } +} diff --git a/reporter-influxdb/src/main/java/io/ultrabrew/metrics/reporters/influxdb/InfluxDBReporter.java b/reporter-influxdb/src/main/java/io/ultrabrew/metrics/reporters/influxdb/InfluxDBReporter.java new file mode 100755 index 0000000..04a7a56 --- /dev/null +++ b/reporter-influxdb/src/main/java/io/ultrabrew/metrics/reporters/influxdb/InfluxDBReporter.java @@ -0,0 +1,141 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.reporters.influxdb; + +import io.ultrabrew.metrics.data.Aggregator; +import io.ultrabrew.metrics.data.Cursor; +import io.ultrabrew.metrics.data.CursorEntry; +import io.ultrabrew.metrics.data.Type; +import io.ultrabrew.metrics.reporters.TimeWindowReporter; +import java.io.IOException; +import java.net.URI; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An aggregating reporter that periodically stores data to InfluxDB. + */ +public class InfluxDBReporter extends TimeWindowReporter { + + private static final Logger LOGGER = LoggerFactory.getLogger(InfluxDBReporter.class); + + private final InfluxDBClient dbClient; + private long lastReportedTimestamp = 0; + + private InfluxDBReporter(final URI dbUri, int windowSeconds, int bufferSize) { + super(dbUri.toString(), windowSeconds); + this.dbClient = new InfluxDBClient(dbUri, bufferSize); + this.start(); + } + + /** + * Create a fluent builder for constructing {@link InfluxDBReporter} instances. + */ + public static Builder builder() { + return new Builder(); + } + + @Override + protected void doReport(Map aggregators) { + long newestTimestamp = 0; + try { + for (final Map.Entry entry : aggregators.entrySet()) { + final Aggregator aggregator = entry.getValue(); + final Cursor cursor = aggregator.cursor(); + final String metricName = entry.getKey(); + while (cursor.next()) { + if (cursor.lastUpdated() > lastReportedTimestamp) { + dbClient.write(metricName, cursor.getTags(), buildFields(cursor), -1); + newestTimestamp = Math.max(newestTimestamp, cursor.lastUpdated()); + } + } + } + dbClient.flush(); + } catch (IOException e) { + LOGGER.error("Failed to send data", e); + } + if (newestTimestamp > 0) { + lastReportedTimestamp = newestTimestamp; + } + } + + private String[] buildFields(CursorEntry cursor) { + final String[] fields = cursor.getFields(); + final Type[] types = cursor.getTypes(); + + String[] result = new String[fields.length * 2]; + for (int i = 0; i < fields.length; i++) { + result[i * 2] = fields[i]; + result[i * 2 + 1] = types[i].readAndReset(cursor, i); + } + return result; + } + + public static class Builder { + + private URI baseUri = null; + private String database = null; + private int windowSeconds = 1; + private int bufferSize = 64 * 1024; + + private Builder() { + } + + /** + * Set the base URI of the InfluxDB installation, for example "http://localhost:8086". + * + * @param baseUri base URI of the InfluxDB + */ + public Builder withBaseUri(final URI baseUri) { + this.baseUri = baseUri; + return this; + } + + /** + * Set the database name measurements are to be written to. + * + * @param database database name + */ + public Builder withDatabase(final String database) { + this.database = database; + return this; + } + + /** + * Set the reporting time window size. + * + * @param windowSeconds reporting time window in seconds + */ + public Builder withWindowSize(int windowSeconds) { + this.windowSeconds = windowSeconds; + return this; + } + + /** + * Set size of the buffer into which measurements are collected for sending to InfluxDB. + * + * @param bufferSize size of buffer in bytes + */ + public Builder withBufferSize(int bufferSize) { + this.bufferSize = bufferSize; + return this; + } + + /** + * Create an {@link InfluxDBReporter} instance. + */ + public InfluxDBReporter build() { + if (baseUri == null) { + throw new IllegalArgumentException("Invalid baseUri"); + } + if (database == null || database.isEmpty()) { + throw new IllegalArgumentException("Invalid database"); + } + return new InfluxDBReporter(baseUri.resolve("/write?db=" + database), windowSeconds, + bufferSize); + } + } +} diff --git a/reporter-influxdb/src/test/java/io/ultrabrew/metrics/reporters/influxdb/InfluxDBClientTest.java b/reporter-influxdb/src/test/java/io/ultrabrew/metrics/reporters/influxdb/InfluxDBClientTest.java new file mode 100755 index 0000000..4982823 --- /dev/null +++ b/reporter-influxdb/src/test/java/io/ultrabrew/metrics/reporters/influxdb/InfluxDBClientTest.java @@ -0,0 +1,133 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.reporters.influxdb; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.net.URI; +import mockit.Expectations; +import mockit.Mocked; +import mockit.Verifications; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.impl.client.CloseableHttpClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class InfluxDBClientTest { + + @Mocked + CloseableHttpClient httpClient; + + @Mocked + StatusLine statusLine; + + @Mocked + CloseableHttpResponse closeableHttpResponse; + + private InfluxDBClient client; + + @BeforeEach + public void before() throws Exception { + client = new InfluxDBClient(URI.create("http://localhost:8086/write?db=test"), 64 * 1024); + } + + @Test + public void testWriteSuccessful() throws Exception { + new Expectations() {{ + httpClient.execute((HttpUriRequest) any); + result = closeableHttpResponse; + closeableHttpResponse.getStatusLine(); + result = statusLine; + statusLine.getStatusCode(); + result = 204; + }}; + String[] tags = {"host", "server01", "region", "us-west"}; + String[] fields = {"temp", "80", "fanSpeed", "743"}; + client.write("cpu_load_short", tags, fields, 1534055562000000003L); + client.write("cpu_load_short", tags, fields, 1534055562000000004L); + client.flush(); + client.write("cpu_load_short", tags, fields, 1534055562000000007L); + client.write("cpu_load_short", tags, fields, 1534055562000000008L); + client.flush(); + } + + @Test + public void testWriteFails() throws Exception { + new Expectations() {{ + httpClient.execute((HttpUriRequest) any); + result = closeableHttpResponse; + closeableHttpResponse.getStatusLine(); + result = statusLine; + statusLine.getStatusCode(); + result = 500; + }}; + String[] tags = {"host", "server01", "region", "us-west"}; + String[] fields = {"temp", "80", "fanSpeed", "743"}; + client.write("cpu_load_short", tags, fields, 1534055562000000003L); + client.write("cpu_load_short", tags, fields, 1534055562000000004L); + assertThrows(IOException.class, client::flush); + client.write("cpu_load_short", tags, fields, 1534055562000000007L); + client.write("cpu_load_short", tags, fields, 1534055562000000008L); + assertThrows(IOException.class, client::flush); + } + + @Test + public void testWriteSplitting() throws IOException { + new Expectations() {{ + httpClient.execute((HttpUriRequest) any); + result = closeableHttpResponse; + closeableHttpResponse.getStatusLine(); + result = statusLine; + statusLine.getStatusCode(); + result = 200; + }}; + + InfluxDBClient c = new InfluxDBClient(URI.create("http://localhost:8086/write?db=test"), 10); + // 4+1+1+1+1 = 8 bytes + c.write("test", new String[]{}, new String[]{"a", "1"}, 0); + // 4+1+1+1+1 = 8 bytes + // Won't fit in buffer (8+8 > 10), should automatically flush previous + c.write("test", new String[]{}, new String[]{"a", "2"}, 0); + c.flush(); + + new Verifications() {{ + httpClient.execute((HttpUriRequest) any); + times = 2; + }}; + } + + @Test + public void testTooLargeMeasurement() throws IOException { + InfluxDBClient c = new InfluxDBClient(URI.create("http://localhost:8086/write?db=test"), 10); + assertThrows(IllegalArgumentException.class, + () -> c.write("thisisaverylongmeasurementname", new String[]{}, + new String[]{"verylongfieldname", "1234567890"}, 0)); + } + + @Test + public void testTooLargeMeasurementAfterOne() throws IOException { + new Expectations() {{ + httpClient.execute((HttpUriRequest) any); + result = closeableHttpResponse; + closeableHttpResponse.getStatusLine(); + result = statusLine; + statusLine.getStatusCode(); + result = 200; + }}; + + InfluxDBClient c = new InfluxDBClient(URI.create("http://localhost:8086/write?db=test"), 10); + c.write("test", new String[]{}, new String[]{"a", "1"}, 0); + assertThrows(IllegalArgumentException.class, + () -> c.write("thisisaverylongmeasurementname", new String[]{}, + new String[]{"verylongfieldname", "1234567890"}, 0)); + + new Verifications() {{ + httpClient.execute((HttpUriRequest) any); + }}; + } +} diff --git a/reporter-influxdb/src/test/java/io/ultrabrew/metrics/reporters/influxdb/InfluxDBReporterTest.java b/reporter-influxdb/src/test/java/io/ultrabrew/metrics/reporters/influxdb/InfluxDBReporterTest.java new file mode 100755 index 0000000..66732c3 --- /dev/null +++ b/reporter-influxdb/src/test/java/io/ultrabrew/metrics/reporters/influxdb/InfluxDBReporterTest.java @@ -0,0 +1,153 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.reporters.influxdb; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.ultrabrew.metrics.Counter; +import io.ultrabrew.metrics.MetricRegistry; +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import mockit.Capturing; +import mockit.Deencapsulation; +import mockit.Expectations; +import mockit.Mocked; +import mockit.Verifications; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.impl.client.CloseableHttpClient; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; + +public class InfluxDBReporterTest { + + private static final URI TEST_URI = URI.create("http://localhost:8086"); + + @Test + public void testMissingBaseUri() { + assertThrows(IllegalArgumentException.class, + () -> InfluxDBReporter.builder().withDatabase("test").build()); + } + + @Test + public void testMissingDatabase() { + assertThrows(IllegalArgumentException.class, + () -> InfluxDBReporter.builder().withBaseUri(TEST_URI).build()); + } + + @Test + public void testSetWindow() { + InfluxDBReporter r = InfluxDBReporter.builder() + .withBaseUri(TEST_URI) + .withDatabase("test") + .withWindowSize(12765) + .build(); + + long actualWindowSizeMillis = Deencapsulation.getField(r, "windowStepSizeMillis"); + assertEquals(12765 * 1000, actualWindowSizeMillis); + } + + @Test + public void testSetBufferSize() { + InfluxDBReporter r = InfluxDBReporter.builder() + .withBaseUri(TEST_URI) + .withDatabase("test") + .withBufferSize(12765) + .build(); + + InfluxDBClient c = Deencapsulation.getField(r, "dbClient"); + ByteBuffer buffer = Deencapsulation.getField(c, "byteBuffer"); + assertEquals(12765, buffer.capacity()); + } + + @Test + public void testReporting(@Mocked CloseableHttpClient httpClient, + @Mocked CloseableHttpResponse closeableHttpResponse, @Mocked StatusLine statusLine) + throws InterruptedException, IOException { + new Expectations() {{ + httpClient.execute((HttpUriRequest) any); + result = closeableHttpResponse; + closeableHttpResponse.getStatusLine(); + result = statusLine; + statusLine.getStatusCode(); + result = 200; + }}; + + MetricRegistry registry = new MetricRegistry(); + InfluxDBReporter reporter = InfluxDBReporter.builder() + .withBaseUri(URI.create("http://localhost:8086")) + .withDatabase("test") + .build(); + registry.addReporter(reporter); + + Counter counter = registry.counter("counter"); + counter.inc("tag", "value"); + + Thread.sleep(3000); + + new Verifications() {{ + httpClient.execute((HttpUriRequest) any); + times = 1; + }}; + } + + @Test + public void testUploadFailedServerError(@Mocked CloseableHttpClient httpClient, + @Mocked CloseableHttpResponse closeableHttpResponse, @Mocked StatusLine statusLine, + @Capturing Logger logger) throws IOException, InterruptedException { + new Expectations() {{ + httpClient.execute((HttpUriRequest) any); + result = closeableHttpResponse; + closeableHttpResponse.getStatusLine(); + result = statusLine; + statusLine.getStatusCode(); + result = 500; + }}; + + MetricRegistry registry = new MetricRegistry(); + InfluxDBReporter reporter = InfluxDBReporter.builder() + .withBaseUri(URI.create("http://localhost:8086")) + .withDatabase("test") + .build(); + registry.addReporter(reporter); + + Counter counter = registry.counter("counter"); + counter.inc("tag", "value"); + + Thread.sleep(3000); + + new Verifications() {{ + logger.error(anyString, withInstanceOf(IOException.class)); + }}; + } + + @Test + public void testUploadFailedException(@Mocked CloseableHttpClient httpClient, + @Capturing Logger logger) throws IOException, InterruptedException { + new Expectations() {{ + httpClient.execute((HttpUriRequest) any); + result = new IOException(); + }}; + + MetricRegistry registry = new MetricRegistry(); + InfluxDBReporter reporter = InfluxDBReporter.builder() + .withBaseUri(URI.create("http://localhost:8086")) + .withDatabase("test") + .build(); + registry.addReporter(reporter); + + Counter counter = registry.counter("counter"); + counter.inc("tag", "value"); + + Thread.sleep(3000); + + new Verifications() {{ + logger.error(anyString, withInstanceOf(IOException.class)); + }}; + } +} diff --git a/reporter-opentsdb/README.md b/reporter-opentsdb/README.md new file mode 100755 index 0000000..b0ce8be --- /dev/null +++ b/reporter-opentsdb/README.md @@ -0,0 +1,23 @@ +# OpenTSDBReporter + +This reporter will push batches of metrics to an OpenTSDB HTTP API server as JSON object. + +Configuration parameters include: + +* **host** - *(required)* A hostname including protocol, host and optional port. E.g. `http://localhost:4242`. Note that the host must start with a protocol of either `http://` or `https://`. +* **endpoint** - *(Default: `/api/put`)* A string with the endpoint to post results to. Note that the endpoint must start with a forward slash and can be `/`. +* **batchSize** - *(Default: `64`)* The maximum number of measurements to flush in each batch. +* **timestampsInMilliseconds** - (Default: `false`) Whether or not to post timestamps in seconds `false` or milliseconds `true`. + +To instantiate and run the reporter execute: + +```Java +OpenTSDBConfig config = OpenTSDBConfig.newBuilder() + .setHost("http://localhost:4242") + .build(); + +// 60 second reporting window +OpenTSDBReporter reporter = new OpenTSDBReporter(config, 60); +MetricRegistry metricRegistry = new MetricRegistry(); +metricRegistry.addReporter(reporter); +``` \ No newline at end of file diff --git a/reporter-opentsdb/build.gradle b/reporter-opentsdb/build.gradle new file mode 100755 index 0000000..70149ad --- /dev/null +++ b/reporter-opentsdb/build.gradle @@ -0,0 +1,4 @@ +dependencies { + compile project(':core') + compile group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.5.6' +} diff --git a/reporter-opentsdb/src/main/java/io/ultrabrew/metrics/reporters/opentsdb/OpenTSDBHttpClient.java b/reporter-opentsdb/src/main/java/io/ultrabrew/metrics/reporters/opentsdb/OpenTSDBHttpClient.java new file mode 100755 index 0000000..05119cb --- /dev/null +++ b/reporter-opentsdb/src/main/java/io/ultrabrew/metrics/reporters/opentsdb/OpenTSDBHttpClient.java @@ -0,0 +1,173 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.reporters.opentsdb; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import org.apache.http.StatusLine; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ByteArrayEntity; +import org.apache.http.entity.ContentType; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.DefaultHttpRequestRetryHandler; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A client that writes metrics in batches to an OpenTSDB HTTP endpoint. Note a connection to the + * host is opened and kept open so the client lacks built-in load balancing at this time. + */ +class OpenTSDBHttpClient { + + private static final Logger LOG = LoggerFactory.getLogger(OpenTSDBHttpClient.class); + + private static final char[] METRIC = "{\"metric\":".toCharArray(); + private static final char[] TIMESTAMP = "\"timestamp\":".toCharArray(); + private static final char[] VALUE = "\"value\":".toCharArray(); + private static final char[] TAGS = "\"tags\":{".toCharArray(); + + protected final ByteArrayOutputStream buffer; + private final CloseableHttpClient httpClient; + private final URI dbUri; + private final int batchSize; + private final boolean timestampsInMilliseconds; + protected final PrintWriter writer; + protected int currentBatchSize = 0; + + public OpenTSDBHttpClient(final URI dbUri, int batchSize, boolean timestampsInMilliseconds) { + this.dbUri = dbUri; + this.batchSize = batchSize; + this.timestampsInMilliseconds = timestampsInMilliseconds; + this.httpClient = getHttpClient(); + buffer = new ByteArrayOutputStream(batchSize * 256); + final OutputStreamWriter utf8_writer = new OutputStreamWriter(buffer, + StandardCharsets.UTF_8.newEncoder()); + writer = new PrintWriter(utf8_writer); + writer.write('['); + } + + // TODO - configs for these values. + private CloseableHttpClient getHttpClient() { + RequestConfig httpRequestConfig = RequestConfig.custom() + .setConnectTimeout(3000) + .setSocketTimeout(3000) + .setConnectionRequestTimeout(3000) + .build(); + + return HttpClients.custom() + .disableCookieManagement() + .setMaxConnPerRoute(2 * 3) + .setMaxConnTotal(2 * 3) + .setDefaultRequestConfig(httpRequestConfig) + .setRetryHandler(new DefaultHttpRequestRetryHandler()) + .build(); + } + + void write(final String metricName, + final String[] tags, + final long timestamp, + final String value) throws IOException { + if (currentBatchSize++ > 0) { + writer.write(','); + } + + writer.write(METRIC); + writeEscapedString(metricName); + writer.write(','); + writer.write(TIMESTAMP); + // TODO we could optimize this out to write directly but... yuk + writer.write(Long.toString((timestampsInMilliseconds ? timestamp : timestamp / 1_000))); + writer.write(','); + writer.write(TAGS); + boolean toggle = true; + for (int i = 0; i < tags.length; i++) { + writeEscapedString(tags[i]); + if (toggle) { + writer.write(':'); + } else if (i + 1 < tags.length) { + writer.write(','); + } + toggle = !toggle; + } + writer.write('}'); // end tags + writer.write(','); + writer.write(VALUE); + writer.write(value); + writer.write('}'); // end obj + + // see if we need to flush it. + if (currentBatchSize >= batchSize) { + flush(); + } + } + + void flush() throws IOException { + if (currentBatchSize < 1) { + return; + } + + // flush + writer.write(']'); + writer.flush(); + HttpPost httpPost = new HttpPost(dbUri); + httpPost.setEntity(new ByteArrayEntity(buffer.toByteArray(), ContentType.APPLICATION_JSON)); + final StatusLine status; + CloseableHttpResponse response = null; + try { + response = httpClient.execute(httpPost); + status = response.getStatusLine(); + // CLOVER:OFF + // Just tracing. + if (LOG.isTraceEnabled()) { + LOG.trace("Response from OpenTSDB [{}] ", + EntityUtils.toString(response.getEntity())); + } + // CLOVER:ON + } finally { + if (response != null) { + EntityUtils.consumeQuietly(response.getEntity()); + } + // reset our buffer and batch size + currentBatchSize = 0; + buffer.reset(); + writer.write('['); + } + if (status.getStatusCode() / 100 != 2) { + throw new IllegalStateException(String.format( + "Failed to write metrics to OpenTSDB '%d' - '%s'", + status.getStatusCode(), + status.getReasonPhrase())); + } + } + + /** + * Escapes quotes and back slashes to make sure we satisfy the JSON spec and surrounds the string + * in quotes. + * + * @param string The non-null and non-empty string to escape. + */ + void writeEscapedString(final String string) { + writer.write('"'); + for (int i = 0; i < string.length(); i++) { + int cp = string.codePointAt(i); + if (Character.isISOControl(cp)) { + throw new IllegalArgumentException("Invalid control character in metric or tag: " + cp); + } + if (cp == '"' || cp == '\\') { + writer.write('\\'); + } + writer.write(cp); + } + writer.write('"'); + } +} \ No newline at end of file diff --git a/reporter-opentsdb/src/main/java/io/ultrabrew/metrics/reporters/opentsdb/OpenTSDBReporter.java b/reporter-opentsdb/src/main/java/io/ultrabrew/metrics/reporters/opentsdb/OpenTSDBReporter.java new file mode 100755 index 0000000..4418c5c --- /dev/null +++ b/reporter-opentsdb/src/main/java/io/ultrabrew/metrics/reporters/opentsdb/OpenTSDBReporter.java @@ -0,0 +1,144 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.reporters.opentsdb; + +import io.ultrabrew.metrics.data.Aggregator; +import io.ultrabrew.metrics.data.Cursor; +import io.ultrabrew.metrics.data.Type; +import io.ultrabrew.metrics.reporters.TimeWindowReporter; +import java.io.IOException; +import java.net.URI; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A reporter that sends data to an OpenTSDB host (or rotation) via HTTP POSTs. It will send batches + * of data to the configured host and endpoint. + * + *

Usage: + *

+ * OpenTSDBReporter reporter = OpenTSDBReporter.builder()
+ *   .withBaseUri(URI.create("http://localhost:4242"))
+ *   .build();
+ * MetricRegistry metricRegistry = new MetricRegistry();
+ * metricRegistry.addReporter(reporter);
+ * 
+ */ +public class OpenTSDBReporter extends TimeWindowReporter { + + private static final Logger LOGGER = LoggerFactory.getLogger(OpenTSDBReporter.class); + private static final String DEFAULT_API_ENDPOINT = "api/v1/put"; + private static final int DEFAULT_BATCH_SIZE = 64; + private final OpenTSDBHttpClient client; + private long lastReportedTimestamp = 0; + + private OpenTSDBReporter(final String name, final OpenTSDBHttpClient client, + final int windowSeconds) { + super(name, windowSeconds); + this.client = client; + this.start(); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + protected void doReport(final Map aggregators) { + long newestTimestamp = 0; + try { + for (final Map.Entry entry : aggregators.entrySet()) { + final Aggregator aggregator = entry.getValue(); + final Cursor cursor = aggregator.cursor(); + final String metricName = entry.getKey(); + while (cursor.next()) { + if (cursor.lastUpdated() > lastReportedTimestamp) { + final String[] fields = cursor.getFields(); + final Type[] types = cursor.getTypes(); + for (int i = 0; i < fields.length; i++) { + client.write(metricName, cursor.getTags(), cursor.lastUpdated(), + types[i].readAndReset(cursor, i)); + } + newestTimestamp = Math.max(newestTimestamp, cursor.lastUpdated()); + } + } + } + client.flush(); + } catch (IOException t) { + LOGGER.error("Failed to send data", t); + } + if (newestTimestamp > 0) { + lastReportedTimestamp = newestTimestamp; + } + } + + public static class Builder { + + private URI baseUri; + private String apiEndpoint = DEFAULT_API_ENDPOINT; + private int batchSize = DEFAULT_BATCH_SIZE; + private boolean timestampsInMilliseconds = true; + private int windowSeconds = 1; + + /** + * Set the base URI of the OpenTSDB installation. The path component of the URI must end with a + * slash. + */ + public Builder withBaseUri(URI baseUri) { + if (!baseUri.getPath().endsWith("/")) { + throw new IllegalArgumentException("Base URI path must end with '/'"); + } + this.baseUri = baseUri; + return this; + } + + /** + * Set the API endpoint path to use. Should not be used unless the OpenTSDB installation is + * behind a load balancer or reverse proxy that does path mapping. This must point to an + * endpoint compatible with v1 of the OpenTSDB HTTP put API. + */ + public Builder withApiEndpoint(String apiEndpoint) { + this.apiEndpoint = apiEndpoint; + return this; + } + + /** + * Sets the number of metrics (measurements) to send in each batch. Defaults to 64. + * + * @param batchSize number of measurements to send in each batch + */ + public Builder withBatchSize(final int batchSize) { + if (batchSize < 1) { + throw new IllegalArgumentException("Batch size must be greater than or equal to 1"); + } + this.batchSize = batchSize; + return this; + } + + /** + * Set the reporting time window size. + * + * @param windowSeconds reporting time window in seconds + */ + public Builder withWindowSize(int windowSeconds) { + this.windowSeconds = windowSeconds; + return this; + } + + /** + * Create an {@link OpenTSDBReporter} instance. + */ + public OpenTSDBReporter build() { + if (baseUri == null) { + throw new IllegalArgumentException("Invalid baseUri"); + } + URI dbUri = baseUri.resolve(apiEndpoint); + OpenTSDBHttpClient client = new OpenTSDBHttpClient(dbUri, batchSize, + timestampsInMilliseconds); + return new OpenTSDBReporter(dbUri.toString(), client, windowSeconds); + } + } +} diff --git a/reporter-opentsdb/src/test/java/io/ultrabrew/metrics/reporters/opentsdb/OpenTSDBHttpClientTest.java b/reporter-opentsdb/src/test/java/io/ultrabrew/metrics/reporters/opentsdb/OpenTSDBHttpClientTest.java new file mode 100755 index 0000000..9543886 --- /dev/null +++ b/reporter-opentsdb/src/test/java/io/ultrabrew/metrics/reporters/opentsdb/OpenTSDBHttpClientTest.java @@ -0,0 +1,126 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.reporters.opentsdb; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import mockit.Expectations; +import mockit.Mocked; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.impl.client.CloseableHttpClient; +import org.junit.jupiter.api.Test; + +public class OpenTSDBHttpClientTest { + + private static final URI DUMMY_DB_URI = URI.create("http://localhost:4242/api/put"); + + @Mocked + private CloseableHttpClient httpClient; + + @Mocked + private StatusLine statusLine; + + @Mocked + private CloseableHttpResponse closeableHttpResponse; + + @Test + public void testWriteSuccessfulManualFlush() throws Exception { + new Expectations() {{ + httpClient.execute((HttpUriRequest) any); + result = closeableHttpResponse; + closeableHttpResponse.getStatusLine(); + result = statusLine; + statusLine.getStatusCode(); + result = 204; + }}; + OpenTSDBHttpClient client = new OpenTSDBHttpClient(DUMMY_DB_URI, 64, true); + String[] tags = {"host", "server01", "region", "us-west"}; + client.write("cpu_load_short.temp", tags, 1534055562000000003L, "80"); + client.write("cpu_load_short.fanSpeed", tags, 1534055562000000004L, "74.3"); + assertEquals(2, client.currentBatchSize); + client.flush(); + assertEquals(0, client.currentBatchSize); + } + + @Test + public void testWriteSuccessfulAutoFlush() throws Exception { + new Expectations() {{ + httpClient.execute((HttpUriRequest) any); + result = closeableHttpResponse; + closeableHttpResponse.getStatusLine(); + result = statusLine; + statusLine.getStatusCode(); + result = 204; + }}; + OpenTSDBHttpClient client = new OpenTSDBHttpClient(DUMMY_DB_URI, 1, true); + String[] tags = {"host", "server01", "region", "us-west"}; + client.write("cpu_load_short.temp", tags, 1534055562000000003L, "80"); + assertEquals(0, client.currentBatchSize); + client.write("cpu_load_short.fanSpeed", tags, 1534055562000000004L, "74.3"); + assertEquals(0, client.currentBatchSize); + } + + @Test + public void testWriteFailsManualFlush() throws Exception { + new Expectations() {{ + httpClient.execute((HttpUriRequest) any); + result = closeableHttpResponse; + closeableHttpResponse.getStatusLine(); + result = statusLine; + statusLine.getStatusCode(); + result = 500; + }}; + OpenTSDBHttpClient client = new OpenTSDBHttpClient(DUMMY_DB_URI, 64, true); + String[] tags = {"host", "server01", "region", "us-west"}; + client.write("cpu_load_short.temp", tags, 1534055562000000003L, "80"); + client.write("cpu_load_short.fanSpeed", tags, 1534055562000000004L, "74.3"); + assertEquals(2, client.currentBatchSize); + assertThrows(IllegalStateException.class, client::flush); + assertEquals(0, client.currentBatchSize); + } + + @Test + public void testEscapeString() throws Exception { + OpenTSDBHttpClient c = new OpenTSDBHttpClient(DUMMY_DB_URI, 64, false); + + c.write("Sîne klâwen durh die wolken \"sint\" geslagen", + new String[]{"host", "web\\01", "colo", "phx"}, 1000000, "42.5"); + c.write("m1", new String[]{"host", "web\\01", "colo", "lga"}, 2000000, "24.6"); + c.writer.flush(); + assertEquals("[{\"metric\":\"Sîne klâwen durh die wolken \\\"sint\\\" geslagen\"," + + "\"timestamp\":1000,\"tags\":{\"host\":\"web\\\\01\",\"colo\":\"phx\"}," + + "\"value\":42.5},{\"metric\":\"m1\",\"timestamp\":2000,\"tags\":" + + "{\"host\":\"web\\\\01\",\"colo\":\"lga\"},\"value\":24.6}", + new String(c.buffer.toByteArray(), StandardCharsets.UTF_8)); + } + + @Test + public void testEscapeStringMillis() throws Exception { + OpenTSDBHttpClient c = new OpenTSDBHttpClient(DUMMY_DB_URI, 64, true); + + c.write("Sîne klâwen durh die wolken \"sint\" geslagen", + new String[]{"host", "web\\01", "colo", "phx"}, 1000000, "42.5"); + c.write("m1", new String[]{"host", "web\\01", "colo", "lga"}, 2000000, "24.6"); + c.writer.flush(); + assertEquals("[{\"metric\":\"Sîne klâwen durh die wolken \\\"sint\\\" geslagen\"," + + "\"timestamp\":1000000,\"tags\":{\"host\":\"web\\\\01\",\"colo\":\"phx\"}," + + "\"value\":42.5},{\"metric\":\"m1\",\"timestamp\":2000000,\"tags\":" + + "{\"host\":\"web\\\\01\",\"colo\":\"lga\"},\"value\":24.6}", + new String(c.buffer.toByteArray(), StandardCharsets.UTF_8)); + } + + @Test + public void testEscapeStringControlChars() { + OpenTSDBHttpClient c = new OpenTSDBHttpClient(DUMMY_DB_URI, 64, true); + + assertThrows(IllegalArgumentException.class, () -> c + .write("test\nmetric", new String[]{"host", "web\\01", "colo", "phx"}, 1000000, "42.5")); + } +} diff --git a/reporter-opentsdb/src/test/java/io/ultrabrew/metrics/reporters/opentsdb/OpenTSDBReporterTest.java b/reporter-opentsdb/src/test/java/io/ultrabrew/metrics/reporters/opentsdb/OpenTSDBReporterTest.java new file mode 100755 index 0000000..c308ea8 --- /dev/null +++ b/reporter-opentsdb/src/test/java/io/ultrabrew/metrics/reporters/opentsdb/OpenTSDBReporterTest.java @@ -0,0 +1,221 @@ +// Copyright 2018, Oath Inc. +// Licensed under the terms of the Apache License 2.0 license. See LICENSE file in Ultrabrew Metrics +// for terms. + +package io.ultrabrew.metrics.reporters.opentsdb; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.ultrabrew.metrics.Counter; +import io.ultrabrew.metrics.MetricRegistry; +import java.io.IOException; +import java.net.URI; +import mockit.Capturing; +import mockit.Deencapsulation; +import mockit.Expectations; +import mockit.Mocked; +import mockit.Verifications; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.impl.client.CloseableHttpClient; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; + +public class OpenTSDBReporterTest { + + private static final URI TEST_URI = URI.create("http://localhost:4242/"); + + private OpenTSDBReporter makeReporter() { + return OpenTSDBReporter.builder() + .withBaseUri(TEST_URI) + .build(); + } + + @Test + public void testReporting(@Mocked CloseableHttpClient httpClient, + @Mocked CloseableHttpResponse closeableHttpResponse, + @Mocked StatusLine statusLine) + throws InterruptedException, IOException { + new Expectations() {{ + httpClient.execute((HttpUriRequest) any); + result = closeableHttpResponse; + closeableHttpResponse.getStatusLine(); + result = statusLine; + statusLine.getStatusCode(); + result = 200; + }}; + + MetricRegistry registry = new MetricRegistry(); + OpenTSDBReporter reporter = makeReporter(); + registry.addReporter(reporter); + + Counter counter = registry.counter("counter"); + counter.inc("tag", "value"); + + Thread.sleep(3000); + + new Verifications() {{ + HttpUriRequest request; + httpClient.execute(request = withCapture()); + assertEquals("/api/v1/put", request.getURI().getPath()); + times = 1; + }}; + } + + @Test + public void testReportingBaseUriWithPath(@Mocked CloseableHttpClient httpClient, + @Mocked CloseableHttpResponse closeableHttpResponse, @Mocked StatusLine statusLine) + throws InterruptedException, IOException { + new Expectations() {{ + httpClient.execute((HttpUriRequest) any); + result = closeableHttpResponse; + closeableHttpResponse.getStatusLine(); + result = statusLine; + statusLine.getStatusCode(); + result = 200; + }}; + + MetricRegistry registry = new MetricRegistry(); + OpenTSDBReporter reporter = OpenTSDBReporter.builder() + .withBaseUri(URI.create("http://localhost:4242/some/path/")) + .build(); + registry.addReporter(reporter); + + Counter counter = registry.counter("counter"); + counter.inc("tag", "value"); + + Thread.sleep(3000); + + new Verifications() {{ + HttpUriRequest request; + httpClient.execute(request = withCapture()); + assertEquals("/some/path/api/v1/put", request.getURI().getPath()); + times = 1; + }}; + } + + @Test + public void testSetApiEndpoint(@Mocked CloseableHttpClient httpClient, + @Mocked CloseableHttpResponse closeableHttpResponse, + @Mocked StatusLine statusLine) + throws InterruptedException, IOException { + new Expectations() {{ + httpClient.execute((HttpUriRequest) any); + result = closeableHttpResponse; + closeableHttpResponse.getStatusLine(); + result = statusLine; + statusLine.getStatusCode(); + result = 200; + }}; + + MetricRegistry registry = new MetricRegistry(); + OpenTSDBReporter reporter = OpenTSDBReporter.builder() + .withBaseUri(URI.create("http://localhost:4242/")) + .withApiEndpoint("very/special/put") + .build(); + registry.addReporter(reporter); + + Counter counter = registry.counter("counter"); + counter.inc("tag", "value"); + + Thread.sleep(3000); + + new Verifications() {{ + HttpUriRequest request; + httpClient.execute(request = withCapture()); + assertEquals("/very/special/put", request.getURI().getPath()); + times = 1; + }}; + } + + @Test + public void testUploadFailedServerError(@Mocked CloseableHttpClient httpClient, + @Mocked CloseableHttpResponse closeableHttpResponse, + @Mocked StatusLine statusLine, + @Capturing Logger logger) throws IOException, InterruptedException { + new Expectations() {{ + httpClient.execute((HttpUriRequest) any); + result = closeableHttpResponse; + closeableHttpResponse.getStatusLine(); + result = statusLine; + statusLine.getStatusCode(); + result = 500; + }}; + + MetricRegistry registry = new MetricRegistry(); + OpenTSDBReporter reporter = makeReporter(); + registry.addReporter(reporter); + + Counter counter = registry.counter("counter"); + counter.inc("tag", "value"); + + Thread.sleep(3000); + + new Verifications() {{ + logger.error(anyString, withInstanceOf(IllegalStateException.class)); + }}; + } + + @Test + public void testUploadFailedException(@Mocked CloseableHttpClient httpClient, + @Capturing Logger logger) throws IOException, InterruptedException { + new Expectations() {{ + httpClient.execute((HttpUriRequest) any); + result = new IOException(); + }}; + + MetricRegistry registry = new MetricRegistry(); + OpenTSDBReporter reporter = makeReporter(); + registry.addReporter(reporter); + + Counter counter = registry.counter("counter"); + counter.inc("tag", "value"); + + Thread.sleep(3000); + + new Verifications() {{ + logger.error(anyString, withInstanceOf(Throwable.class)); + }}; + } + + @Test + public void testSetWindow() { + OpenTSDBReporter r = OpenTSDBReporter.builder() + .withBaseUri(TEST_URI) + .withWindowSize(12765) + .build(); + + long actualWindowSizeMillis = Deencapsulation.getField(r, "windowStepSizeMillis"); + assertEquals(12765 * 1000, actualWindowSizeMillis); + } + + @Test + public void testSetBatchSize() { + OpenTSDBReporter r = OpenTSDBReporter.builder() + .withBaseUri(TEST_URI) + .withBatchSize(123) + .build(); + + OpenTSDBHttpClient client = Deencapsulation.getField(r, "client"); + int actualBatchSize = Deencapsulation.getField(client, "batchSize"); + assertEquals(123, actualBatchSize); + } + + @Test + public void testMissingBaseUri() { + assertThrows(IllegalArgumentException.class, () -> OpenTSDBReporter.builder().build()); + } + + @Test + public void testInvalidBaseUri() { + assertThrows(IllegalArgumentException.class, + () -> OpenTSDBReporter.builder().withBaseUri(URI.create("http://localhost:4242"))); + } + + @Test + public void testInvalidBatchSize() { + assertThrows(IllegalArgumentException.class, () -> OpenTSDBReporter.builder().withBatchSize(0)); + } +} diff --git a/settings.gradle b/settings.gradle new file mode 100755 index 0000000..261e334 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,5 @@ +rootProject.name = 'metrics' + +include 'core', 'benchmark' +include 'reporter-influxdb', 'reporter-opentsdb' +include 'examples:webapp', 'examples:undertow-httphandler'