├── .gitignore ├── .pairs ├── LICENSE ├── NOTICE ├── README.md ├── bin ├── deploy.rb └── dev-deploy.sh ├── build.gradle ├── circle.yml ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src ├── main ├── kotlin │ └── org │ │ └── cloudfoundry │ │ └── loggregator │ │ └── logmon │ │ ├── HomeController.kt │ │ ├── LogMonMonitor.kt │ │ ├── LogmonApplication.kt │ │ ├── WebSecurityConfig.kt │ │ ├── anomalies │ │ ├── AnomalyLevel.kt │ │ ├── AnomalyRepo.kt │ │ ├── AnomalyStateMachine.kt │ │ └── ApplicationAnomaly.kt │ │ ├── cf │ │ ├── CfApplicationEnv.kt │ │ ├── CfConfiguration.kt │ │ └── LogStreamer.kt │ │ ├── logs │ │ ├── LogConsumer.kt │ │ └── LogProducer.kt │ │ ├── pacman │ │ ├── LogConsumptionTask.kt │ │ ├── LogProductionTask.kt │ │ ├── LogSink.kt │ │ ├── LogTestExecution.kt │ │ ├── Pacman.kt │ │ └── Printer.kt │ │ └── statistics │ │ ├── LogTestExecutionResults.kt │ │ ├── LogTestExecutionsRepo.kt │ │ ├── StatisticsPresenter.kt │ │ ├── metricExtensions.kt │ │ └── metricNames.kt └── resources │ ├── application.yml │ ├── static │ ├── css │ │ ├── app.css │ │ └── pui.css │ └── js │ │ ├── app.js │ │ └── graph.js │ └── templates │ ├── index.html │ └── stats │ └── index.html └── test └── kotlin └── org └── cloudfoundry └── loggregator └── logmon ├── DashboardUiTest.kt ├── LogmonApplicationTests.kt ├── StatisticsUiTest.kt ├── cf └── LogStreamerTest.kt ├── pacman ├── LogProductionTaskTest.kt ├── LogSinkTest.kt ├── LogTestExecutionSpringTest.kt ├── LogTestExecutionTest.kt └── PacmanTest.kt ├── statistics ├── AnomalyStateMachineTest.kt └── StatisticsPresenterTest.kt └── support ├── htmlHelpers.kt ├── kotlinAny.kt └── xmlTestingExtensions.kt /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | 5 | ### STS ### 6 | .apt_generated 7 | .classpath 8 | .factorypath 9 | .project 10 | .settings 11 | .springBeans 12 | 13 | ### IntelliJ IDEA ### 14 | .idea 15 | *.iws 16 | *.iml 17 | *.ipr 18 | 19 | ### NetBeans ### 20 | nbproject/private/ 21 | build/ 22 | nbbuild/ 23 | dist/ 24 | nbdist/ 25 | .nb-gradle/ -------------------------------------------------------------------------------- /.pairs: -------------------------------------------------------------------------------- 1 | # .pairs - configuration for 'git pair' 2 | pairs: 3 | # : [; ] 4 | tj: TJ Taylor; ttaylor 5 | mp: Michael Pace; mpace 6 | cw: Chris Wang; cwang 7 | md: Mark Douglass; mdouglass 8 | ge: Greg Eng; geng 9 | email: 10 | prefix: pair 11 | domain: pivotal.io 12 | no_solo_prefix: true 13 | global: false 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-Present CloudFoundry.org Foundation, Inc. All Rights Reserved. 2 | 3 | This project is licensed to you under the Apache License, Version 2.0 (the "License"). 4 | You may not use this project except in compliance with the License. 5 | 6 | This project may include a number of subcomponents with separate copyright notices 7 | and license terms. Your use of these subcomponents is subject to the terms and 8 | conditions of the subcomponent's license, as noted in the LICENSE file. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NOTE: This release has been deprecated and is not supported. 2 | 3 | # cf-logmon 4 | 5 | This application performs a blacbox test for measuring message reliability 6 | when running the command `cf logs`. This is accomplished by writing groups of 7 | logs, measuring the time it took to produce the logs, and then counting the 8 | logs received in the log stream. This is one way to measure message 9 | reliability of the Loggregator system. The results of this test are displayed 10 | in a simple UI and available via JSON and the Firehose. 11 | 12 | 13 | ## Setup 14 | 15 | To get started you'll need to create a user that you would like to use within 16 | the app (we recommend creating a specific user for performing the test rather 17 | than using "real" credentials). You will also need to install the jdk if you 18 | are not setup for Java development. 19 | 20 | 1. Create a space auditor user. 21 | The application needs this user to read logs. 22 | Because its credentials will be stored in an environment variable in CF, 23 | **these credentials should not belong to any real user.** 24 | An example set of commands to create a user: 25 | ```bash 26 | ORG=my-org 27 | SPACE=my-space 28 | USERNAME=logmon-user@mycompany.com 29 | PASSWORD=fancy-password 30 | cf create-user 31 | cf set-space-role SpaceAuditor 32 | ``` 33 | 34 | 1. ```bash 35 | git clone https://github.com/cloudfoundry-incubator/cf-logmon 36 | cd cf-logmon 37 | bin/deploy.rb 38 | ``` 39 | 40 | Then visit `https://cf-logmon.$CF_DOMAIN`. 41 | You should start seeing data within 5 minutes. 42 | 43 | We've found that statistics become more valuable after 24 hours (or after a 44 | typical business day). 45 | 46 | ## Configuration 47 | 48 | The following environment variables are used to configure test output and 49 | rates: 50 | 51 | * `LOGMON_SKIP_CERT_VERIFY` - Whether to skip ssl validation when connecting to CF 52 | 53 | * `LOGMON_PRODUCTION_LOG_CYCLES` - The number of logs to emit during each test 54 | * `LOGMON_PRODUCTION_LOG_DURATION_MILLIS` - The amount of time in milliseconds 55 | during which the logs will be emitted. 56 | * `LOGMON_PRODUCTION_LOG_BYTE_SIZE` - The byte size of the log message to emit. 57 | 58 | It is also possible to configure various wait times: 59 | 60 | * `LOGMON_TIME_BETWEEN_TESTS_MILLIS` - The amount of time to wait between each 61 | "test" 62 | * `LOGMON_CONSUMPTION_POST_PRODUCTION_WAIT_TIME_MILLIS` - The amount of time 63 | to wait after production completes for all created logs to drain. 64 | * `LOGMON_PRODUCTION_INITIAL_DELAY_MILLIS` - The amount of time to allow a log 65 | consumption connection to start before producing logs. 66 | 67 | **Important** Do not scale this application beyond a single instance. Nothing 68 | is done to distinquish app instances when consuming logs. 69 | 70 | ## Web UI 71 | 72 | This application includes a simple user interface for understanding your loss 73 | rate over the last 24 hours. The chart shoes the specific performance over the 74 | last 24 hours. The anamoly journal shows events when your log reliability 75 | rates falls below 99% (warning) and 90% (alert). This is a general guide to 76 | help operators better understand how to configure metrics. 77 | 78 | ## Firehose Metrics 79 | 80 | This application works best whenbound to the 81 | [metrics-forwarder](https://network.pivotal.io/products/p-metrics-forwarder) 82 | service. This allows the following metrics to be emitted by the application. 83 | 84 | * `metrics_forwarder.gauge.logmon.logs_produced` 85 | * `metrics_forwarder.gauge.logmon.logs_consumed` 86 | 87 | The metrics are tagged with the application GUID of the app that is pushed. 88 | 89 | ## Background 90 | 91 | Due to the challenges of distributed systems, and untracked srouces of loss in 92 | the Loggregator system setting Service Level Objectives for message 93 | reliability has been difficult using whitebox monitoring tools. The 94 | Loggregator team developed series of blackbox tests to monitor and mesaure 95 | message reliability. This was developed as a stand alone applications through 96 | a collaboration with Pivotal Labs in Denver. 97 | -------------------------------------------------------------------------------- /bin/deploy.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'tempfile' 4 | require 'yaml' 5 | require 'securerandom' 6 | 7 | trap 'INT' do 8 | puts 9 | puts "Bye bye..." 10 | exit 11 | end 12 | 13 | puts <<-USER_CREATION 14 | 15 | ******************************************************************************** 16 | 17 | ▄████▄ █████▒ ██▓ ▒█████ ▄████ ███▄ ▄███▓ ▒█████ ███▄ █ 18 | ▒██▀ ▀█ ▓██ ▒ ▓██▒ ▒██▒ ██▒ ██▒ ▀█▒▓██▒▀█▀ ██▒▒██▒ ██▒ ██ ▀█ █ 19 | ▒▓█ ▄ ▒████ ░ ▒██░ ▒██░ ██▒▒██░▄▄▄░▓██ ▓██░▒██░ ██▒▓██ ▀█ ██▒ 20 | ▒▓▓▄ ▄██▒░▓█▒ ░ ▒██░ ▒██ ██░░▓█ ██▓▒██ ▒██ ▒██ ██░▓██▒ ▐▌██▒ 21 | ▒ ▓███▀ ░░▒█░ ░██████▒░ ████▓▒░░▒▓███▀▒▒██▒ ░██▒░ ████▓▒░▒██░ ▓██░ 22 | ░ ░▒ ▒ ░ ▒ ░ ░ ▒░▓ ░░ ▒░▒░▒░ ░▒ ▒ ░ ▒░ ░ ░░ ▒░▒░▒░ ░ ▒░ ▒ ▒ 23 | ░ ▒ ░ ░ ░ ▒ ░ ░ ▒ ▒░ ░ ░ ░ ░ ░ ░ ▒ ▒░ ░ ░░ ░ ▒░ 24 | ░ ░ ░ ░ ░ ░ ░ ░ ▒ ░ ░ ░ ░ ░ ░ ░ ░ ▒ ░ ░ ░ 25 | ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ 26 | ░ 27 | ******************************************************************************** 28 | 29 | Welcome to CF-Logmon, we're about to do some setup. While you wait, 30 | you may wish to create a user with the space-auditor role for us to act as. 31 | As a reminder, this user is specifically intended to read cf-logmon logs. 32 | Moreover, this user's credentials will be accessible in the CF environment. 33 | Therefore, it should not be you or any other human user. 34 | 35 | An example set of commands to create such a user: 36 | 37 | cf create-user 38 | cf set-space-role SpaceAuditor 39 | 40 | Note: keep track of the username and password. We will you ask you for these credentials in a bit. 41 | 42 | USER_CREATION 43 | 44 | Dir.chdir(File.expand_path('../', __dir__)) do 45 | puts 'Building application locally ... this may take a while.' 46 | system './gradlew clean assemble -Dorg.gradle.project.version=1.0.x > /dev/null' 47 | end 48 | 49 | username = 'admin' 50 | password = SecureRandom.uuid 51 | 52 | application_name = ARGV[0] || 'logmon' 53 | 54 | puts <<-ABOUT_TO_PUSH 55 | 56 | ******************************************************************************** 57 | 58 | THE TIME HAS COME! 59 | 60 | We're about to push cf-logmon to this target: 61 | 62 | #{`cf target`} 63 | 64 | If any of the above information does not look correct, please hit CTRL+C now. 65 | 66 | Please enter the credentials you created earlier below. 67 | Note: they will be echoed in plaintext - this is to reinforce that the credentials should be ephemeral 68 | and not belong to a real user. 69 | 70 | ABOUT_TO_PUSH 71 | 72 | print "Space Auditor Username: " 73 | log_reader_username = $stdin.gets.chomp 74 | 75 | print "Space Auditor Password: " 76 | log_reader_password = $stdin.gets.chomp 77 | 78 | print "Log Cycles Per Test: (default: 1000) " 79 | log_cycles = $stdin.gets.chomp 80 | 81 | print "Log Byte Size: (default: 256) " 82 | log_byte_size = $stdin.gets.chomp 83 | 84 | print "Log Duration Milliseconds Per Test: (default: 1000) " 85 | log_duration_millis = $stdin.gets.chomp 86 | 87 | print "Skip SSL Validation? [y/N] " 88 | skip_ssl = $stdin.gets.chomp 89 | 90 | skip_validate_ssl = false 91 | if skip_ssl == "y" 92 | skip_validate_ssl = true 93 | end 94 | 95 | if log_cycles.empty? 96 | log_cycles = 1000 97 | end 98 | 99 | if log_byte_size.empty? 100 | log_byte_size = 256 101 | end 102 | 103 | if log_duration_millis.empty? 104 | log_duration_millis = 1000 105 | end 106 | 107 | puts 108 | 109 | manifest = { 110 | 'applications' => [{ 111 | 'name' => application_name, 112 | 'path' => File.expand_path('../build/libs/logmon-1.0.x.jar', __dir__), 113 | 'env' => { 114 | 'LOGMON_AUTH_USERNAME' => username, 115 | 'LOGMON_AUTH_PASSWORD' => password, 116 | 'LOGMON_CONSUMPTION_USERNAME' => log_reader_username, 117 | 'LOGMON_CONSUMPTION_PASSWORD' => log_reader_password, 118 | 'LOGMON_PRODUCTION_LOG_CYCLES' => log_cycles, 119 | 'LOGMON_PRODUCTION_LOG_BYTE_SIZE' => log_byte_size, 120 | 'LOGMON_PRODUCTION_LOG_DURATION_MILLIS' => log_duration_millis, 121 | 'LOGMON_SKIP_CERT_VERIFY' => skip_validate_ssl, 122 | } 123 | }] 124 | } 125 | 126 | Tempfile.open('manifest') do |f| 127 | f.write YAML.dump(manifest) 128 | f.close 129 | 130 | puts 'Deploying application ...' 131 | system "cf push -f #{f.path}" 132 | end 133 | 134 | puts <<-NOTICE 135 | 136 | ******************************************************************************** 137 | 138 | The application deployed successfully! 139 | 140 | You can now visit the UI at: https://#{`cf app #{application_name} | grep routes | awk '{print $2}'`.chomp} 141 | 142 | The UI is protected with HTTP Basic Auth. The username and password are: 143 | 144 | Username: #{username} 145 | Password: #{password} 146 | 147 | If you want to change these credentials, run the following commands: 148 | 149 | cf set-env #{application_name} LOGMON_AUTH_USERNAME 150 | cf set-env #{application_name} LOGMON_AUTH_PASSWORD 151 | 152 | ******************************************************************************** 153 | 154 | NOTICE 155 | -------------------------------------------------------------------------------- /bin/dev-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | CF=$(which cf) 4 | if [[ -z "$CF" ]]; then 5 | CF=/tmp/cf 6 | fi 7 | 8 | $(dirname $0)/../gradlew clean assemble -Dorg.gradle.project.version=1.0.$PATCH_NUM 9 | $CF login -a "$CF_API" -u "$CF_USER" -p "$CF_PASSWORD" -s "$CF_SPACE" 10 | $CF push "$CF_APPNAME" -p $(dirname $0)/../build/libs/logmon-1.0.$PATCH_NUM.jar -b https://github.com/cloudfoundry/java-buildpack 11 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | springBootVersion = '1.5.4.RELEASE' 4 | kotlinVersion = '1.2.70' 5 | cfClientVersion = '2.15.0.RELEASE' 6 | reactorVersion = '3.0.7.RELEASE' 7 | reactorNettyVersion = '0.6.3.RELEASE' 8 | mockitoKotlinVersion = '1.5.0' 9 | jsr310Version = '2.8.9' 10 | } 11 | repositories { 12 | mavenCentral() 13 | } 14 | dependencies { 15 | classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") 16 | classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") 17 | } 18 | } 19 | 20 | apply plugin: 'java' 21 | apply plugin: 'kotlin' 22 | apply plugin: 'eclipse' 23 | apply plugin: 'org.springframework.boot' 24 | 25 | version = "${version}" 26 | sourceCompatibility = 1.8 27 | 28 | repositories { 29 | mavenCentral() 30 | } 31 | 32 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { 33 | kotlinOptions { 34 | jvmTarget = "1.8" 35 | } 36 | } 37 | 38 | dependencies { 39 | compile("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") 40 | compile("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion") 41 | compile('org.springframework.boot:spring-boot-starter-thymeleaf') 42 | compile('org.springframework.boot:spring-boot-starter-web') 43 | //compile('org.springframework.boot:spring-boot-actuator') 44 | compile("org.springframework.boot:spring-boot-starter-actuator") 45 | compile("org.springframework.boot:spring-boot-starter-security") 46 | 47 | compile("org.cloudfoundry:cloudfoundry-client-reactor:$cfClientVersion") 48 | compile("org.cloudfoundry:cloudfoundry-operations:$cfClientVersion") 49 | compile("io.projectreactor:reactor-core:$reactorVersion") 50 | compile("io.projectreactor.ipc:reactor-netty:$reactorNettyVersion") 51 | compile("com.fasterxml.jackson.module:jackson-module-parameter-names:$jsr310Version") 52 | compile("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:$jsr310Version") 53 | compile("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jsr310Version") 54 | 55 | testCompile('org.springframework.boot:spring-boot-starter-test') 56 | testCompile("io.projectreactor.addons:reactor-test:$reactorVersion") 57 | testCompile("com.nhaarman:mockito-kotlin-kt1.1:$mockitoKotlinVersion") 58 | } 59 | 60 | defaultTasks 'clean', 'test' 61 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | pre: 3 | - curl -L "https://cli.run.pivotal.io/stable?release=linux64-binary&source=github" > /tmp/cf-cli.tgz && cd /tmp && tar zxf cf-cli.tgz 4 | deployment: 5 | review: 6 | branch: master 7 | commands: 8 | - PATCH_NUM=$CIRCLE_BUILD_NUM ./bin/dev-deploy.sh 9 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | project.version=1.0.0-SNAPSHOT 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudfoundry-attic/cf-logmon/742080134fc69b9fea39b8634b5c470ea08af5e7/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-bin.zip 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn ( ) { 37 | echo "$*" 38 | } 39 | 40 | die ( ) { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save ( ) { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'logmon' 2 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/HomeController.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon 2 | 3 | import org.cloudfoundry.loggregator.logmon.anomalies.AnomalyRepo 4 | import org.cloudfoundry.loggregator.logmon.anomalies.ApplicationAnomaly 5 | import org.cloudfoundry.loggregator.logmon.statistics.LogTestExecutionResults 6 | import org.cloudfoundry.loggregator.logmon.statistics.LogTestExecutionsRepo 7 | import org.cloudfoundry.loggregator.logmon.statistics.StatisticsPresenter 8 | import org.springframework.beans.factory.annotation.Autowired 9 | import org.springframework.stereotype.Controller 10 | import org.springframework.ui.Model 11 | import org.springframework.web.bind.annotation.GetMapping 12 | import org.springframework.web.bind.annotation.ResponseBody 13 | import java.text.SimpleDateFormat 14 | import java.time.Instant 15 | import java.time.LocalDateTime 16 | import java.time.ZoneOffset 17 | import java.time.Duration 18 | import java.util.* 19 | 20 | @Controller 21 | class HomeController @Autowired constructor( 22 | private val logTestExecutionsRepo: LogTestExecutionsRepo, 23 | private val anomalyRepo: AnomalyRepo, 24 | private val statistics: StatisticsPresenter 25 | ) { 26 | @GetMapping(path = arrayOf("/"), produces = arrayOf("text/html")) 27 | fun index(model: Model): String { 28 | val presenter = HomePagePresenter( 29 | logTestExecutionsRepo.findAll(), 30 | anomalyRepo.findAll(), 31 | statistics 32 | ) 33 | model.addAttribute("page", presenter) 34 | return "index" 35 | } 36 | 37 | @GetMapping(path = arrayOf("/stats"), produces = arrayOf("text/html")) 38 | fun statsIndex(model: Model): String { 39 | model.addAttribute("testResults", logTestExecutionsRepo.findAll()) 40 | return "stats/index" 41 | } 42 | 43 | @GetMapping(path = arrayOf("/tests"), produces = arrayOf("application/json")) 44 | @ResponseBody 45 | fun testIndex(): List { 46 | return logTestExecutionsRepo.findAll() 47 | } 48 | 49 | private class HomePagePresenter(val results: List, val anomalies: List, statistics: StatisticsPresenter) { 50 | val todaysReliability = presentReliability(statistics.reliability( 51 | results.filter { it.startTime > LocalDateTime.now().minusDays(1L).toInstant(ZoneOffset.UTC) } 52 | )) 53 | 54 | val allTimeReliability = presentReliability(statistics.reliability(results)) 55 | val allTimeDuration = statistics.runTime(results) 56 | val hasMultidayData = allTimeDuration >= Duration.ofDays(1) 57 | val allTimeDateRange 58 | get() = 59 | if (hasMultidayData) { 60 | listOf(pp(results.first().startTime), pp(results.last().startTime)).joinToString(" - ") 61 | } else { 62 | "" 63 | } 64 | 65 | val today = pp(Instant.now()) 66 | 67 | private fun pp(time: Instant): String { 68 | return SimpleDateFormat("M/d/YY").format(Date(time.toEpochMilli())) 69 | } 70 | 71 | private fun presentReliability(reliability: Double): String { 72 | return String.format("%.2f", reliability * 100) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/LogMonMonitor.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon 2 | 3 | import org.cloudfoundry.loggregator.logmon.statistics.LogTestExecutionsRepo 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.beans.factory.annotation.Autowired 6 | import org.springframework.scheduling.annotation.Scheduled 7 | import org.springframework.stereotype.Component 8 | 9 | @Component 10 | class LogMonMonitor @Autowired constructor(private val resultsRepo: LogTestExecutionsRepo) { 11 | companion object { 12 | private val log = LoggerFactory.getLogger(LogMonMonitor::class.java) 13 | } 14 | 15 | @Scheduled(fixedDelay = 1000) 16 | fun sweepTheHouse() { 17 | val runtime = Runtime.getRuntime() 18 | if (runtime.freeMemory().toFloat() / runtime.totalMemory().toFloat() < 0.1) { 19 | val numItems = (resultsRepo.findAll().size * 0.1).toInt() 20 | log.info("Deleting first $numItems items from the resultsRepo") 21 | resultsRepo.deleteFirst(numItems) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/LogmonApplication.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon 2 | 3 | import org.springframework.boot.SpringApplication 4 | import org.springframework.boot.actuate.metrics.CounterService 5 | import org.springframework.boot.actuate.metrics.repository.InMemoryMetricRepository 6 | import org.springframework.boot.actuate.metrics.repository.MetricRepository 7 | import org.springframework.boot.actuate.metrics.writer.DefaultCounterService 8 | import org.springframework.boot.autoconfigure.SpringBootApplication 9 | import org.springframework.context.annotation.Bean 10 | import org.springframework.context.annotation.Configuration 11 | import org.springframework.context.annotation.Profile 12 | import org.springframework.scheduling.annotation.EnableScheduling 13 | 14 | @SpringBootApplication 15 | open class LogmonApplication { 16 | companion object { 17 | @JvmStatic fun main(args: Array) { 18 | SpringApplication.run(LogmonApplication::class.java, *args) 19 | } 20 | } 21 | 22 | @Configuration 23 | @EnableScheduling 24 | @Profile("!test") 25 | open class SchedulingConfiguration 26 | 27 | @Configuration 28 | @Profile("test") 29 | open class TestConfiguration 30 | 31 | @Bean 32 | open fun counterService(metricRepository: MetricRepository): CounterService = DefaultCounterService(metricRepository) 33 | 34 | @Bean 35 | open fun metricRepository(): MetricRepository = InMemoryMetricRepository() 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/WebSecurityConfig.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon 2 | 3 | import org.springframework.beans.factory.annotation.Autowired 4 | import org.springframework.beans.factory.annotation.Value 5 | import org.springframework.context.annotation.Configuration 6 | import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder 7 | import org.springframework.security.config.annotation.web.builders.HttpSecurity 8 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity 9 | import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter 10 | 11 | 12 | @Configuration 13 | @EnableWebSecurity 14 | open class WebSecurityConfig : WebSecurityConfigurerAdapter() { 15 | override fun configure(http: HttpSecurity) { 16 | http.authorizeRequests().anyRequest() 17 | .fullyAuthenticated() 18 | .and() 19 | .httpBasic() 20 | .and() 21 | .csrf().disable() 22 | } 23 | 24 | @Value("\${logmon.auth.username}") 25 | private lateinit var basicAuthUsername: String 26 | @Value("\${logmon.auth.password}") 27 | private lateinit var basicAuthPassword: String 28 | 29 | @Autowired 30 | fun configureGlobal(auth: AuthenticationManagerBuilder) { 31 | auth 32 | .inMemoryAuthentication() 33 | .withUser(basicAuthUsername).password(basicAuthPassword).roles("USER") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/anomalies/AnomalyLevel.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.anomalies 2 | 3 | enum class AnomalyLevel { 4 | RED { 5 | override fun message(percentageReliability: Double) = 6 | String.format( 7 | "The average reliability rate for the last 5 tests is %.0f%%. " + 8 | "Click \"Review Data\" in the chart to see more info on the logs.", 9 | percentageReliability 10 | ) 11 | }, 12 | YELLOW { 13 | override fun message(percentageReliability: Double): String = 14 | String.format("Reliability Rate %.0f%%", percentageReliability) 15 | }, 16 | GREEN { 17 | override fun message(percentageReliability: Double): String = 18 | String.format("Reliability Rate %.0f%%", percentageReliability) 19 | }; 20 | 21 | open fun message(percentageReliability: Double) = "" 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/anomalies/AnomalyRepo.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.anomalies 2 | 3 | import org.springframework.stereotype.Component 4 | import java.time.Instant 5 | 6 | @Component 7 | open class AnomalyRepo { 8 | private var anomalies: MutableList = mutableListOf() 9 | 10 | open fun findAll(): List { 11 | return anomalies 12 | } 13 | 14 | open fun save(description: String, level: AnomalyLevel) { 15 | anomalies.add(ApplicationAnomaly(description, Instant.now(), level)) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/anomalies/AnomalyStateMachine.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.anomalies 2 | 3 | import org.cloudfoundry.loggregator.logmon.statistics.LogTestExecutionsRepo 4 | import org.cloudfoundry.loggregator.logmon.statistics.StatisticsPresenter 5 | import org.springframework.beans.factory.annotation.Autowired 6 | import org.springframework.beans.factory.annotation.Value 7 | import org.springframework.stereotype.Component 8 | import javax.annotation.PostConstruct 9 | 10 | @Component 11 | open class AnomalyStateMachine @Autowired constructor( 12 | private val anomalyRepo: AnomalyRepo, 13 | private val logTestExecutionsRepo: LogTestExecutionsRepo, 14 | private val statistics: StatisticsPresenter 15 | ) { 16 | @PostConstruct 17 | protected open fun initialize() { 18 | anomalyRepo.save("Deploy successful, collecting data", AnomalyLevel.GREEN) 19 | } 20 | 21 | @Value("\${logmon.anomalies.sample-size}") 22 | private var WINDOW_WIDTH = 5 23 | 24 | private var state: AnomalyLevel = AnomalyLevel.GREEN 25 | 26 | open fun recalculate() { 27 | val lastN = logTestExecutionsRepo.findAll().takeLast(WINDOW_WIDTH) 28 | if (lastN.size < WINDOW_WIDTH) return 29 | val reliability = statistics.reliability(lastN) 30 | if ((0.9..0.99).contains(reliability) && state != AnomalyLevel.YELLOW) { 31 | state = AnomalyLevel.YELLOW 32 | anomalyRepo.save(state.message(reliability * 100), state) 33 | } else if (reliability < 0.9 && state != AnomalyLevel.RED) { 34 | state = AnomalyLevel.RED 35 | anomalyRepo.save(state.message(reliability * 100), state) 36 | } else if (reliability > 0.99 && state != AnomalyLevel.GREEN) { 37 | state = AnomalyLevel.GREEN 38 | anomalyRepo.save(state.message(reliability * 100), state) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/anomalies/ApplicationAnomaly.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.anomalies 2 | 3 | import java.time.Instant 4 | 5 | data class ApplicationAnomaly(val description: String, val timestamp: Instant, val level: AnomalyLevel) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/cf/CfApplicationEnv.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.cf 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties 4 | import org.springframework.stereotype.Component 5 | import java.net.URI 6 | 7 | @Component 8 | @ConfigurationProperties("vcap.application") 9 | open class CfApplicationEnv { 10 | lateinit var name: String 11 | lateinit var cfApi: URI 12 | lateinit var spaceId: String 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/cf/CfConfiguration.kt: -------------------------------------------------------------------------------- 1 | 2 | package org.cloudfoundry.loggregator.logmon.cf 3 | 4 | import org.cloudfoundry.client.CloudFoundryClient 5 | import org.cloudfoundry.doppler.DopplerClient 6 | import org.cloudfoundry.reactor.ConnectionContext 7 | import org.cloudfoundry.reactor.DefaultConnectionContext 8 | import org.cloudfoundry.reactor.TokenProvider 9 | import org.cloudfoundry.reactor.client.ReactorCloudFoundryClient 10 | import org.cloudfoundry.reactor.doppler.ReactorDopplerClient 11 | import org.cloudfoundry.reactor.tokenprovider.PasswordGrantTokenProvider 12 | import org.cloudfoundry.reactor.uaa.ReactorUaaClient 13 | import org.cloudfoundry.uaa.UaaClient 14 | import org.springframework.boot.cloud.CloudFoundryVcapEnvironmentPostProcessor 15 | import org.springframework.boot.context.properties.ConfigurationProperties 16 | import org.springframework.context.annotation.Bean 17 | import org.springframework.beans.factory.annotation.Value 18 | import org.springframework.context.annotation.Configuration 19 | 20 | @Configuration 21 | open class CfConfiguration { 22 | 23 | @Configuration 24 | @ConfigurationProperties("logmon.consumption") 25 | open class CfLoginInfo { 26 | lateinit var username: String 27 | lateinit var password: String 28 | } 29 | 30 | @Value("\${logmon.skip-cert-verify}") 31 | private var skipCertVerify: Boolean = false 32 | 33 | @Bean 34 | open fun cfApiHost(cfApplicationEnv: CfApplicationEnv): String = cfApplicationEnv.cfApi.host 35 | 36 | @Bean 37 | open fun cloudFoundryVcapEnvironmentPostProcessor() = CloudFoundryVcapEnvironmentPostProcessor() 38 | 39 | @Bean 40 | open fun uaaClient(connectionContext: ConnectionContext, tokenProvider: TokenProvider): UaaClient = 41 | ReactorUaaClient.builder() 42 | .connectionContext(connectionContext) 43 | .tokenProvider(tokenProvider) 44 | .build() 45 | 46 | @Bean 47 | open fun connectionContext(cfApiHost: String): DefaultConnectionContext { 48 | return DefaultConnectionContext.builder() 49 | .apiHost(cfApiHost) 50 | .skipSslValidation(skipCertVerify) 51 | .build() 52 | } 53 | 54 | @Bean 55 | open fun tokenProvider(cfLoginInfo: CfLoginInfo): TokenProvider { 56 | return PasswordGrantTokenProvider.builder() 57 | .username(cfLoginInfo.username) 58 | .password(cfLoginInfo.password) 59 | .build() 60 | } 61 | 62 | @Bean 63 | open fun cloudFoundryClient(connectionContext: ConnectionContext, tokenProvider: TokenProvider): CloudFoundryClient { 64 | return ReactorCloudFoundryClient.builder() 65 | .connectionContext(connectionContext) 66 | .tokenProvider(tokenProvider) 67 | .build() 68 | } 69 | 70 | @Bean 71 | open fun dopplerClient(connectionContext: ConnectionContext, tokenProvider: TokenProvider): DopplerClient { 72 | return ReactorDopplerClient.builder() 73 | .connectionContext(connectionContext) 74 | .tokenProvider(tokenProvider) 75 | .build() 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/cf/LogStreamer.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.cf 2 | 3 | import org.cloudfoundry.client.CloudFoundryClient 4 | import org.cloudfoundry.client.v2.organizations.GetOrganizationRequest 5 | import org.cloudfoundry.client.v2.organizations.GetOrganizationResponse 6 | import org.cloudfoundry.client.v2.spaces.GetSpaceRequest 7 | import org.cloudfoundry.client.v2.spaces.GetSpaceResponse 8 | import org.cloudfoundry.doppler.DopplerClient 9 | import org.cloudfoundry.doppler.LogMessage 10 | import org.cloudfoundry.operations.DefaultCloudFoundryOperations 11 | import org.cloudfoundry.operations.applications.GetApplicationRequest 12 | import org.cloudfoundry.operations.applications.LogsRequest 13 | import org.cloudfoundry.uaa.UaaClient 14 | import org.springframework.beans.factory.annotation.Autowired 15 | import org.springframework.stereotype.Component 16 | import reactor.core.publisher.Flux 17 | 18 | @Component 19 | open class LogStreamer @Autowired constructor( 20 | val cloudFoundryClient: CloudFoundryClient, 21 | val dopplerClient: DopplerClient, 22 | val uaaClient: UaaClient, 23 | val cfApplicationEnv: CfApplicationEnv 24 | ) { 25 | data class Application(val orgName: String, val spaceName: String, val appName: String) 26 | 27 | open fun logStreamForApplication(application: Application): Flux { 28 | val logsReq = LogsRequest.builder().name(application.appName).build() 29 | 30 | return client(application.orgName, application.spaceName).applications().logs(logsReq) 31 | } 32 | 33 | open fun fetchApplicationByName(name: String): Application? { 34 | val space = fetchSpaceById(cfApplicationEnv.spaceId) 35 | val organization = fetchOrganizationById(space.entity.organizationId) 36 | 37 | return client(organization.entity.name, space.entity.name).applications() 38 | .get(GetApplicationRequest.builder().name(name).build()) 39 | .map { Application(organization.entity.name, space.entity.name, it.name) } 40 | .block() 41 | } 42 | 43 | fun fetchSpaceById(spaceId: String): GetSpaceResponse = 44 | cloudFoundryClient.spaces() 45 | .get(GetSpaceRequest.builder().spaceId(spaceId).build()) 46 | .block() 47 | 48 | private fun fetchOrganizationById(orgId: String): GetOrganizationResponse = 49 | cloudFoundryClient.organizations() 50 | .get(GetOrganizationRequest.builder().organizationId(orgId).build()) 51 | .block() 52 | 53 | private var _client: DefaultCloudFoundryOperations? = null 54 | 55 | private fun client(orgId: String, spaceId: String): DefaultCloudFoundryOperations { 56 | if (_client == null) { 57 | _client = DefaultCloudFoundryOperations.builder() 58 | .cloudFoundryClient(cloudFoundryClient) 59 | .dopplerClient(dopplerClient) 60 | .uaaClient(uaaClient) 61 | .organization(orgId) 62 | .space(spaceId) 63 | .build() 64 | } 65 | return _client!! 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/logs/LogConsumer.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.logs 2 | 3 | import reactor.core.publisher.Mono 4 | 5 | interface LogConsumer { 6 | fun consume(productionCompleteNotifier: Mono): Mono 7 | } 8 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/logs/LogProducer.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.logs 2 | 3 | interface LogProducer { 4 | fun produce() 5 | } 6 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/pacman/LogConsumptionTask.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.pacman 2 | 3 | import org.cloudfoundry.loggregator.logmon.logs.LogConsumer 4 | import reactor.core.publisher.Mono 5 | import java.util.function.Supplier 6 | 7 | class LogConsumptionTask(val logConsumer: LogConsumer, val productionCompleteNotifier: Mono) : Supplier { 8 | override fun get(): Long { 9 | return logConsumer.consume(productionCompleteNotifier).block() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/pacman/LogProductionTask.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.pacman 2 | 3 | import org.cloudfoundry.loggregator.logmon.logs.LogProducer 4 | import org.cloudfoundry.loggregator.logmon.statistics.LOG_WRITE_TIME_MILLIS 5 | import org.cloudfoundry.loggregator.logmon.statistics.setImmediate 6 | import org.slf4j.Logger 7 | import org.slf4j.LoggerFactory 8 | import org.springframework.boot.actuate.metrics.repository.MetricRepository 9 | import reactor.core.publisher.Flux 10 | import java.time.Duration 11 | import java.util.function.Supplier 12 | 13 | class LogProductionTask(val logProducer: LogProducer, val metricRepository: MetricRepository, val durationMillis: Int, val numPellets: Int) : Supplier> { 14 | companion object { 15 | val log: Logger = LoggerFactory.getLogger(LogProductionTask::class.java) 16 | } 17 | 18 | /** 19 | * Generate logs at given intervals. 20 | * For pellet counts up to 1000, we can generate a reasonable delay between log production events. 21 | * When the pellet count goes over 1000, the JVM can't guarantee sub-ms precision, so we emit multiple per 22 | * millisecond. Any pellets that cannot be evenly distributed are front-loaded on the first M milliseconds. 23 | * 24 | * With 2 pellets: 25 | * 26 | * Time: 0------------500------------1000 27 | * 28 | * Logs: 1L-----------1L-------------0L -- Complete 29 | * 30 | * 31 | * With 2002 pellets: 32 | * 33 | * Time: 0---1---2---.....-----------1000 34 | * 35 | * Logs: 3L--3L--2L--.....-----------2L -- Complete 36 | */ 37 | override fun get(): Flux { 38 | log.info("Production starting") 39 | 40 | val flux = if (numPellets <= durationMillis) { 41 | val writeRate = durationMillis / numPellets 42 | 43 | Flux.interval(Duration.ofMillis(0), Duration.ofMillis(writeRate.toLong())) 44 | .doOnNext({ logProducer.produce() }) 45 | .take(numPellets.toLong()) 46 | } else { 47 | Flux.interval(Duration.ofMillis(0), Duration.ofMillis(1)) 48 | .doOnNext({ repeat(numPellets / durationMillis + if (numPellets % durationMillis > it) 1 else 0) { 49 | logProducer.produce(); 50 | } 51 | }) 52 | .take(durationMillis.toLong()) 53 | } 54 | 55 | return flux 56 | .map { } 57 | .doOnComplete { 58 | metricRepository.setImmediate(LOG_WRITE_TIME_MILLIS, durationMillis) 59 | log.info("Production complete") 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/pacman/LogSink.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.pacman 2 | 3 | import org.cloudfoundry.loggregator.logmon.cf.CfApplicationEnv 4 | import org.cloudfoundry.loggregator.logmon.cf.LogStreamer 5 | import org.cloudfoundry.loggregator.logmon.logs.LogConsumer 6 | import org.cloudfoundry.loggregator.logmon.statistics.LOGS_CONSUMED 7 | import org.slf4j.Logger 8 | import org.slf4j.LoggerFactory 9 | import org.springframework.beans.factory.annotation.Autowired 10 | import org.springframework.beans.factory.annotation.Value 11 | import org.springframework.boot.actuate.metrics.CounterService 12 | import org.springframework.stereotype.Component 13 | import reactor.core.publisher.Mono 14 | import java.time.Duration 15 | 16 | const val VALID_MESSAGE_PATTERN = "Printer" 17 | 18 | @Component 19 | open class LogSink @Autowired constructor( 20 | val cfApplicationEnv: CfApplicationEnv, 21 | val logStreamer: LogStreamer, 22 | val counterService: CounterService 23 | ) : LogConsumer { 24 | companion object { 25 | val log: Logger = LoggerFactory.getLogger(LogSink::class.java) 26 | } 27 | 28 | @Value("\${logmon.consumption.post-production-wait-time-millis}") 29 | private var postProductionWaitTime: Long = 10_000L 30 | 31 | override fun consume(productionCompleteNotifier: Mono): Mono { 32 | log.info("Beginning consumption") 33 | val application = logStreamer.fetchApplicationByName(cfApplicationEnv.name)!! 34 | return logStreamer.logStreamForApplication(application) 35 | .filter { it.message.contains(VALID_MESSAGE_PATTERN) } 36 | .takeUntilOther( 37 | Mono.delay(Duration.ofMillis(postProductionWaitTime)) 38 | .delaySubscription(productionCompleteNotifier) 39 | ) 40 | .doOnComplete { log.info("Consumption complete") } 41 | .count() 42 | .onErrorResume { Mono.just(-1) } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/pacman/LogTestExecution.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.pacman 2 | 3 | import org.cloudfoundry.loggregator.logmon.anomalies.AnomalyStateMachine 4 | import org.cloudfoundry.loggregator.logmon.statistics.* 5 | import org.slf4j.LoggerFactory 6 | import org.springframework.beans.factory.annotation.Autowired 7 | import org.springframework.beans.factory.annotation.Value 8 | import org.springframework.boot.actuate.metrics.CounterService 9 | import org.springframework.boot.actuate.metrics.Metric 10 | import org.springframework.boot.actuate.metrics.repository.MetricRepository 11 | import org.springframework.scheduling.annotation.Scheduled 12 | import org.springframework.stereotype.Component 13 | import java.util.* 14 | 15 | @Component 16 | open class LogTestExecution @Autowired constructor( 17 | private val printer: Printer, 18 | private val logSink: LogSink, 19 | private val counterService: CounterService, 20 | private val metricRepository: MetricRepository, 21 | private val logTestExecutionsRepo: LogTestExecutionsRepo, 22 | private val stateMachine: AnomalyStateMachine 23 | ) { 24 | companion object { 25 | private val log = LoggerFactory.getLogger(this::class.java) 26 | } 27 | 28 | @Value("\${logmon.production.log-cycles}") 29 | private var logCycles: Int = 1000 30 | 31 | @Value("\${logmon.production.log-duration-millis}") 32 | private var logDurationMillis: Int = 1000 33 | 34 | @Value("\${logmon.production.initial-delay-millis}") 35 | private var productionDelayMillis = 10_000L 36 | 37 | @Scheduled(fixedDelayString = "\${logmon.time-between-tests-millis}", initialDelay = 1000) 38 | open fun runTest() { 39 | log.info("LogTest commencing: ${Date()}") 40 | metricRepository.set(Metric(LAST_EXECUTION_TIME, 0, Date())) 41 | counterService.reset(LOGS_PRODUCED) 42 | counterService.reset(LOGS_CONSUMED) 43 | 44 | Pacman(printer, logSink, metricRepository, logDurationMillis, logCycles, productionDelayMillis).begin() 45 | .doOnSuccess { metricRepository.setImmediate(LOGS_CONSUMED, it) } 46 | .doFinally { 47 | logTestExecutionsRepo.save(LogTestExecutionResults( 48 | metricRepository.findCounter(LOGS_PRODUCED), 49 | metricRepository.findCounter(LOGS_CONSUMED), 50 | metricRepository.findOne(LAST_EXECUTION_TIME).timestamp.toInstant(), 51 | metricRepository.findDouble(LOG_WRITE_TIME_MILLIS) 52 | )) 53 | stateMachine.recalculate() 54 | } 55 | .block() 56 | log.info("LogTest complete: ${Date()}") 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/pacman/Pacman.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.pacman 2 | 3 | import org.cloudfoundry.loggregator.logmon.logs.LogConsumer 4 | import org.cloudfoundry.loggregator.logmon.logs.LogProducer 5 | import org.springframework.boot.actuate.metrics.repository.MetricRepository 6 | import reactor.core.publisher.Mono 7 | import java.time.Duration 8 | 9 | open class Pacman( 10 | val logProducer: LogProducer, 11 | val logConsumer: LogConsumer, 12 | val metricRepository: MetricRepository, 13 | val durationMillis: Int, 14 | val numPellets: Int, 15 | val productionDelayMillis: Long 16 | ) { 17 | fun begin(): Mono { 18 | val productionTask = LogProductionTask(logProducer, metricRepository, durationMillis, numPellets).get() 19 | .delaySubscription(Duration.ofMillis(productionDelayMillis)) 20 | .publish().autoConnect() 21 | .ignoreElements() 22 | .subscribe() 23 | 24 | val consumptionTask = Mono.defer { 25 | Mono.just(LogConsumptionTask(logConsumer, productionTask).get()) 26 | }.subscribe() 27 | 28 | return productionTask.then(consumptionTask) 29 | .log(LogConsumptionTask::class.java.name) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/pacman/Printer.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.pacman 2 | 3 | import org.cloudfoundry.loggregator.logmon.logs.LogProducer 4 | import org.cloudfoundry.loggregator.logmon.statistics.LOGS_PRODUCED 5 | import org.slf4j.LoggerFactory 6 | import org.springframework.beans.factory.annotation.Autowired 7 | import org.springframework.beans.factory.annotation.Value 8 | import org.springframework.boot.actuate.metrics.CounterService 9 | import org.springframework.stereotype.Component 10 | 11 | @Component 12 | open class Printer @Autowired constructor(private val counterService: CounterService) : LogProducer { 13 | private val log = LoggerFactory.getLogger(Printer::class.java) 14 | 15 | @Value("\${logmon.production.log-byte-size}") 16 | private var logByteSize: Long = 256L 17 | 18 | override fun produce() { 19 | counterService.increment(LOGS_PRODUCED) 20 | log.info((1..logByteSize).map({ "0" }).joinToString("")) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/statistics/LogTestExecutionResults.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.statistics 2 | 3 | import java.time.Instant 4 | 5 | data class LogTestExecutionResults( 6 | val logsProduced: Int, 7 | val logsConsumed: Int, 8 | val startTime: Instant, 9 | val productionDuration: Double 10 | ) { 11 | val writeRateDisplay: String 12 | get() { 13 | return if (productionDuration == 0.0) 14 | "$logsProduced logs / < 1 ms" 15 | else 16 | """ 17 | $logsProduced logs / ${String.format("%.02f", productionDuration)} ms 18 | = 19 | ${String.format("%.02f", logsProduced / productionDuration)} logs/ms 20 | """ 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/statistics/LogTestExecutionsRepo.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.statistics 2 | 3 | import org.springframework.stereotype.Repository 4 | import org.springframework.boot.actuate.metrics.GaugeService; 5 | import org.springframework.beans.factory.annotation.Autowired 6 | 7 | @Repository 8 | open class LogTestExecutionsRepo { 9 | private var allResults: MutableList = mutableListOf() 10 | 11 | @Autowired 12 | lateinit var gaugeService:GaugeService 13 | 14 | open fun findAll(): List { 15 | return allResults 16 | } 17 | 18 | open fun save(results: LogTestExecutionResults) { 19 | synchronized(this) { 20 | gaugeService.submit("logmon.logs_produced", 21 | results.logsProduced.toDouble()) 22 | gaugeService.submit("logmon.logs_consumed", 23 | results.logsConsumed.toDouble()) 24 | allResults.add(results) 25 | } 26 | } 27 | 28 | open fun deleteFirst(n: Int = 1) { 29 | synchronized(this) { 30 | repeat(n) { 31 | allResults.removeAt(0) 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/statistics/StatisticsPresenter.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.statistics 2 | 3 | import org.springframework.stereotype.Component 4 | import java.time.Duration 5 | 6 | @Component 7 | class StatisticsPresenter { 8 | fun reliability(results: List): Double { 9 | val validResults = results.filter { it.logsConsumed >= 0 } 10 | if (validResults.count() == 0) { 11 | return 0.0 12 | } else { 13 | val rate = validResults 14 | .map { it.logsConsumed } 15 | .sum() / (validResults.first().logsProduced * validResults.count()).toDouble() 16 | return rate 17 | } 18 | } 19 | 20 | fun runTime(results: List): Duration { 21 | if (results.isEmpty()) return Duration.ZERO 22 | 23 | val sorted = results.sortedBy { it.startTime } 24 | return Duration.between(sorted.first().startTime, sorted.last().startTime) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/statistics/metricExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.statistics 2 | 3 | import org.springframework.boot.actuate.metrics.Metric 4 | import org.springframework.boot.actuate.metrics.repository.MetricRepository 5 | import java.util.* 6 | 7 | fun MetricRepository.findCounter(metricName: String): Int { 8 | return findOne("counter.$metricName")?.value?.toInt() ?: 0 9 | } 10 | 11 | fun MetricRepository.findDouble(metricName: String): Double { 12 | return findOne("counter.$metricName")?.value?.toDouble() ?: 0.0 13 | } 14 | 15 | fun MetricRepository.setImmediate(metricName: String, value: Number) { 16 | this.set(Metric("counter.$metricName", value, Date())) 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/org/cloudfoundry/loggregator/logmon/statistics/metricNames.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.statistics 2 | 3 | const val LOGS_CONSUMED = "logmon.logs.read" 4 | const val LOGS_PRODUCED = "logmon.logs.written" 5 | const val LAST_EXECUTION_TIME = "logmon.logs.written" 6 | const val LOG_WRITE_TIME_MILLIS = "logmon.logs.write_time" 7 | 8 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | metrics: 3 | enabled: true 4 | sensitive: false 5 | 6 | vcap: 7 | application: 8 | name: unknown 9 | cf-api: http://example.com 10 | space-id: unknown 11 | 12 | logmon: 13 | skip-cert-verify: false 14 | anomalies: 15 | sample-size: 5 16 | auth: 17 | password: password 18 | username: admin 19 | consumption: 20 | password: unknown 21 | post-production-wait-time-millis: 10000 22 | username: nope@nope.com 23 | production: 24 | log-cycles: 1000 25 | log-byte-size: 256 26 | log-duration-millis: 1000 27 | initial-delay-millis: 10000 28 | time-between-tests-millis: 60000 29 | 30 | --- 31 | 32 | spring: 33 | profiles: dev 34 | thymeleaf: 35 | cache: false 36 | 37 | vcap: 38 | application: 39 | name: cf-logmon 40 | cf-api: https://api.run.pez.pivotal.io 41 | space-id: 92340jkjd09j293ej 42 | 43 | cf: 44 | username: ttaylor+cf-logmon@pivotal.io 45 | password: FAIL 46 | -------------------------------------------------------------------------------- /src/main/resources/static/css/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Lato', sans-serif; 3 | } 4 | 5 | .content-wrapper { 6 | display: flex; 7 | flex-wrap: wrap; 8 | margin-left: 24px; 9 | margin-right: 24px; 10 | } 11 | 12 | .left-side, .right-side { 13 | display: flex; 14 | } 15 | 16 | .left-side { 17 | flex: 1 1 50%; 18 | flex-flow: column wrap; 19 | } 20 | 21 | .right-side { 22 | flex: 1; 23 | margin-left: 24px; 24 | min-width: 450px; 25 | } 26 | 27 | [class*="col-md-*"] { 28 | padding-left: 20px; 29 | padding-right: 20px; 30 | } 31 | 32 | .panel-title > a, 33 | .panel-title > small, 34 | .panel-title > .small, 35 | .panel-title > small > a, 36 | .panel-title > .small > a { 37 | color: #3E98CA; 38 | } 39 | 40 | .panel { 41 | -webkit-box-shadow: 2px 2px 4px 0px rgba(182,182,182,1); 42 | -moz-box-shadow: 2px 2px 4px 0px rgba(182,182,182,1); 43 | box-shadow: 2px 2px 4px 0px rgba(182,182,182,1); 44 | border: 1px solid rgba(182,182,182,0.25); 45 | border-radius: 5px; 46 | } 47 | 48 | .anomaly-journal-panel .panel-body { 49 | padding: 0 20px; 50 | } 51 | 52 | .header { 53 | background-color: #EFF0F0; 54 | height: 56px; 55 | display: flex; 56 | align-items: center; 57 | justify-content: space-between; 58 | padding: 0 16px; 59 | border-top: 64px solid teal; 60 | box-sizing: content-box; 61 | } 62 | 63 | .material-icons { 64 | color: #15786E; 65 | font-size: 36px; 66 | } 67 | 68 | .info-link { 69 | display: flex; 70 | align-items: center; 71 | color: inherit; 72 | } 73 | 74 | .info-link:hover { 75 | color: inherit; 76 | } 77 | 78 | .panel { 79 | margin-top: 24px; 80 | } 81 | 82 | .panel-header { 83 | position: relative; 84 | padding-left: 20px; 85 | border: none; 86 | } 87 | 88 | .panel-header:after { 89 | position: absolute; 90 | content: ' '; 91 | width: calc(100% - 40px); 92 | border-bottom: 2px solid rgba(0, 0, 0, .1); 93 | bottom: 0; 94 | left: 20px; 95 | } 96 | 97 | .tooltip { 98 | position: absolute; 99 | text-align: center; 100 | width: 60px; 101 | height: 28px; 102 | padding: 2px; 103 | font: 12px sans-serif; 104 | border: 0px; 105 | border-radius: 8px; 106 | pointer-events: none; 107 | } 108 | 109 | .metric-wrapper { 110 | line-height: 1; 111 | margin: 0; 112 | padding-bottom: 16px; 113 | color: #136C92; 114 | position: relative; 115 | } 116 | 117 | .metric-wrapper:after { 118 | border-bottom: 2px solid #899CAC; 119 | content: ' '; 120 | position: absolute; 121 | bottom: 0; 122 | width: 74%; 123 | left: 13%; 124 | } 125 | 126 | .metric-value { 127 | font-size: 84px; 128 | } 129 | 130 | .metric-unit { 131 | font-size: 42px; 132 | font-weight: 300; 133 | } 134 | 135 | .metrics { 136 | display: flex; 137 | justify-content: space-around; 138 | } 139 | 140 | .metric { 141 | margin: 24px 0; 142 | display: flex; 143 | flex: 1; 144 | flex-direction: column; 145 | align-items: center; 146 | border-right: 3px solid rgba(0, 0, 0, 0.1); 147 | } 148 | 149 | .metric:last-child { 150 | border-right: none; 151 | } 152 | 153 | .contextual-date { 154 | font-weight: 300; 155 | color: #136C92; 156 | font-size: 24px; 157 | padding: 16px; 158 | } 159 | 160 | .graph { 161 | min-height: 400px; 162 | } 163 | 164 | .graph-panel .panel-header:after { 165 | border: none; 166 | } 167 | 168 | .graph-panel .panel-title { 169 | width: 100%; 170 | display: flex; 171 | justify-content: space-between; 172 | } 173 | 174 | .graph-panel .panel-title a { 175 | text-transform: uppercase; 176 | margin-right: 8px; 177 | font-size: 12px; 178 | } 179 | 180 | tbody tr:nth-child(odd) { 181 | background-color: #F4F4F4; 182 | } 183 | 184 | .anomaly-green { 185 | color: green; 186 | } 187 | 188 | .anomaly-red { 189 | color: red; 190 | } 191 | 192 | .anomaly-yellow { 193 | color: #B7AF54; 194 | } 195 | 196 | time { 197 | white-space: nowrap; 198 | } 199 | -------------------------------------------------------------------------------- /src/main/resources/static/js/app.js: -------------------------------------------------------------------------------- 1 | const timeNodes = Array.from(document.querySelectorAll('time')); 2 | timeNodes.forEach(function (node) { 3 | const time = new Date(node.innerText); 4 | node.innerHTML = `${time.toLocaleTimeString()}
${time.toLocaleDateString()}` 5 | }); 6 | -------------------------------------------------------------------------------- /src/main/resources/static/js/graph.js: -------------------------------------------------------------------------------- 1 | const TRANSITION_DURATION = 200; //millis 2 | const DOT_RADIUS_SMALL = 3.5; 3 | const DOT_RADIUS_LARGE = 10; 4 | const BACKGROUND_COLOR = '#D9F0FF'; 5 | const CONSUMED_COLOR = '#00A79D'; 6 | const PRODUCED_COLOR = 'black'; 7 | const PRODUCED_CIRCLE_COLOR = '#5FB0DF'; 8 | const CONSUMED_CIRCLE_COLOR = '#88E0F9'; 9 | 10 | const now = new Date(); 11 | const ONE_DAY = 24 * 60 * 60 * 1000; 12 | 13 | const margin = {top: 40, right: 20, bottom: 50, left: 30}; 14 | const width = document.querySelector('.panel-body.graph').clientWidth - margin.left - margin.right; 15 | const height = document.querySelector('.panel-body.graph').clientHeight - margin.top - margin.bottom; 16 | 17 | // Get the data 18 | function retrieveDataAndGraphIt() { 19 | d3.json("tests", function (error, data) { 20 | d3.select('.panel-body.graph').html(""); 21 | if (error) throw error; 22 | if (data.length === 0) { 23 | d3.select(".panel-body.graph") 24 | .style('display', 'flex') 25 | .style('justify-content', 'center') 26 | .style('align-items', 'center') 27 | .text("No data available just yet."); 28 | return; 29 | } 30 | const svg = d3.select(".panel-body.graph") 31 | .insert("svg", 'table') 32 | .attr("style", 'width: 100%') 33 | .attr("width", width + margin.left + margin.right) 34 | .attr("height", height + margin.top + margin.bottom) 35 | .append("g") 36 | .attr("transform", "translate(" + margin.left + "," + margin.top + ") scale(0.975)"); 37 | 38 | const renderText = (text, root, x, y) => root.append('text').attr('x', x).attr('y', y).style('text-anchor', 'middle').text(text); 39 | 40 | renderText('Time', svg, width / 2, height + margin.top + 10); 41 | renderText('Number of Logs', svg, -height / 2, -margin.left / 2 + 5).style('transform', 'rotate(-90deg)'); 42 | 43 | const legendRectSize = 18; 44 | const legendSpacing = 4; 45 | const legend = svg.selectAll('.legend') 46 | .data([{name: "Logs Produced", color: PRODUCED_COLOR}, {name: "Logs Consumed", color: CONSUMED_COLOR}]) 47 | .enter().append('g') 48 | .attr('class', 'legend') 49 | .attr('transform', function (d, i) { 50 | const x = -6 + i * 104; 51 | const y = -margin.top; 52 | return `translate(${x},${y})`; 53 | }); 54 | legend.append('rect') 55 | .attr('transform', 'translate(0, 8)') 56 | .attr('width', legendRectSize) 57 | .attr('height', 2) 58 | .style('fill', d => d.color) 59 | .style('stroke', d => d.color); 60 | legend.append('text') 61 | .attr('x', legendRectSize + legendSpacing) 62 | .attr('y', legendRectSize - legendSpacing) 63 | .style('font-size', '12px') 64 | .text(d => d.name); 65 | 66 | data = data.filter(d => d.logsConsumed >= 0); 67 | 68 | data.forEach(function (d) { 69 | d.startTime = new Date(d.startTime * 1000); 70 | d.logsProduced = +d.logsProduced; 71 | d.logsConsumed = +d.logsConsumed; 72 | }); 73 | 74 | const x = d3.scaleTime() 75 | .range([50, width]) 76 | .domain(d3.extent(data, d => d.startTime)); 77 | const maxY = d3.max(data, d => Math.max(d.logsProduced, d.logsConsumed)); 78 | const y = d3.scaleLinear() 79 | .range([height, 0]) 80 | .domain([ 81 | Math.max(d3.min(data, d => Math.min(d.logsProduced, d.logsConsumed)) - (maxY * 0.2), 0), 82 | maxY * 1.2 83 | ]); 84 | 85 | // Add the X Axis 86 | const xAxis = svg.append("g") 87 | .attr("transform", "translate(0," + height + ")") 88 | .call(d3.axisBottom(x)); 89 | xAxis.select('.domain').remove(); 90 | 91 | // Add the Y Axis 92 | const yAxis = svg.append("g") 93 | .call(d3.axisRight(y).tickSize(width + 50)); 94 | yAxis.select('.domain').remove(); 95 | yAxis.selectAll(".tick line").attr("stroke", "#EEEEEE").attr("stroke-width", "2"); 96 | yAxis.selectAll(".tick text").attr("x", 4).attr("dy", -4); 97 | 98 | const valueline = yProp => d3.line() 99 | .x(d => x(d.startTime)) 100 | .y(d => y(d[yProp])); 101 | 102 | const renderLine = function (root, lineColor, prop) { 103 | root.append("path") 104 | .data([data]) 105 | .attr("class", "line") 106 | .style("fill", "none") 107 | .style("stroke", lineColor) 108 | .attr("d", valueline(prop)); 109 | }; 110 | 111 | renderLine(svg, PRODUCED_COLOR, "logsProduced"); 112 | renderLine(svg, CONSUMED_COLOR, "logsConsumed"); 113 | 114 | const tooltip = d3.select(".panel-body.graph").append("div") 115 | .attr("class", "tooltip bg-neutral-6") 116 | .style("opacity", 0); 117 | 118 | const renderDots = function (root, points, dotColor, prop) { 119 | root.selectAll("dot") 120 | .data(points.filter((p, i) => i === 0 || points[i][prop] !== points[i-1][prop])) 121 | .enter() 122 | .append("circle") 123 | .attr("stroke", dotColor) 124 | .attr("fill", BACKGROUND_COLOR) 125 | .attr("r", DOT_RADIUS_SMALL) 126 | .attr("cx", d => x(d.startTime)) 127 | .attr("cy", d => y(d[prop])) 128 | .on("mouseover", function (d) { 129 | tooltip.transition() 130 | .duration(TRANSITION_DURATION) 131 | .style("opacity", .9); 132 | tooltip.html(d3.timeFormat("%d-%b-%y")(d.startTime) + "
" + d[prop]) 133 | .style("left", (d3.event.offsetX) + "px") 134 | .style("top", (d3.event.offsetY - 28) + "px"); 135 | transitionToSizeAndColor(this, DOT_RADIUS_LARGE, dotColor); 136 | }) 137 | .on("mouseout", function () { 138 | tooltip.transition() 139 | .duration(TRANSITION_DURATION) 140 | .style("opacity", 0); 141 | transitionToSizeAndColor(this, DOT_RADIUS_SMALL, BACKGROUND_COLOR); 142 | }); 143 | }; 144 | 145 | renderDots(svg, data, CONSUMED_CIRCLE_COLOR, "logsConsumed"); 146 | }); 147 | } 148 | 149 | function transitionToSizeAndColor(item, size, color) { 150 | d3.select(item) 151 | .transition() 152 | .duration(TRANSITION_DURATION) 153 | .attr('r', size) 154 | .attr('fill', color) 155 | } 156 | 157 | retrieveDataAndGraphIt(); 158 | setInterval(retrieveDataAndGraphIt, 5000); 159 | -------------------------------------------------------------------------------- /src/main/resources/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test Executions 6 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
Logmon Dashboard
16 | 17 | info_outline 18 | Info 19 | 20 |
21 |
22 |
23 | 24 |
25 |
26 |
Log Reliability
27 |
28 |
29 |
30 |

31 | 32 | % 33 |

34 |

Today

35 |

36 |
37 |
38 |

39 | 40 | % 41 |

42 |

44 |

45 |

46 |
47 |
48 |
49 |
50 |
51 |
52 | Test Log Chart 53 | 54 | Review Data 55 | 56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
Anomaly Journal
66 |
67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 81 | 82 | 89 | 90 | 91 |
TimeEvent Summary
79 | 80 | 83 | 84 | check_circle 85 | warning 86 | warning 87 | 88 |
92 |
93 |
94 |
95 |
96 | 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /src/main/resources/templates/stats/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test Executions 6 | 7 | 8 | 9 |
10 |
11 |
Logmon Data
12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
Execution TimeLogs ProducedLogs ConsumedWrite Rate
33 |
34 |
35 | 36 | 37 | -------------------------------------------------------------------------------- /src/test/kotlin/org/cloudfoundry/loggregator/logmon/DashboardUiTest.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.cloudfoundry.loggregator.logmon.anomalies.AnomalyLevel 5 | import org.cloudfoundry.loggregator.logmon.anomalies.AnomalyRepo 6 | import org.cloudfoundry.loggregator.logmon.anomalies.ApplicationAnomaly 7 | import org.cloudfoundry.loggregator.logmon.pacman.LogTestExecution 8 | import org.cloudfoundry.loggregator.logmon.statistics.LogTestExecutionResults 9 | import org.cloudfoundry.loggregator.logmon.statistics.LogTestExecutionsRepo 10 | import org.cloudfoundry.loggregator.logmon.support.page 11 | import org.cloudfoundry.loggregator.logmon.support.text 12 | import org.junit.Before 13 | import org.junit.Test 14 | import org.junit.runner.RunWith 15 | import org.mockito.Mockito.`when` 16 | import org.springframework.beans.factory.annotation.Autowired 17 | import org.springframework.boot.context.embedded.LocalServerPort 18 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc 19 | import org.springframework.boot.test.context.SpringBootTest 20 | import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT 21 | import org.springframework.boot.test.mock.mockito.MockBean 22 | import org.springframework.boot.test.web.client.TestRestTemplate 23 | import org.springframework.test.context.TestPropertySource 24 | import org.springframework.test.context.ActiveProfiles 25 | import org.springframework.test.context.junit4.SpringRunner 26 | import java.time.Instant 27 | 28 | @RunWith(SpringRunner::class) 29 | @SpringBootTest(webEnvironment = RANDOM_PORT) 30 | @AutoConfigureMockMvc 31 | @ActiveProfiles("test") 32 | class DashboardUiTest { 33 | @MockBean 34 | private lateinit var logTestExecution: LogTestExecution 35 | 36 | @MockBean 37 | private lateinit var logTestExecutionsRepo: LogTestExecutionsRepo 38 | 39 | @MockBean 40 | private lateinit var anomalyRepo: AnomalyRepo 41 | 42 | @LocalServerPort 43 | private var port: Int = -1 44 | 45 | private val baseUrl 46 | get() = "http://localhost:$port" 47 | 48 | @Autowired 49 | private lateinit var http: TestRestTemplate 50 | 51 | val now by lazy { Instant.now() } 52 | 53 | @Before 54 | fun setUp() { 55 | `when`(logTestExecutionsRepo.findAll()).thenReturn(listOf( 56 | LogTestExecutionResults(10_000, 9_500, now, 2000.0), 57 | LogTestExecutionResults(10_000, 8_777, now, 2000.0), 58 | LogTestExecutionResults(10_000, 5_000, now.minusSeconds(3600 * 24 * 2), 2000.0) 59 | )) 60 | 61 | `when`(anomalyRepo.findAll()).thenReturn(listOf( 62 | ApplicationAnomaly("Deploy Successful, collecting data", Instant.now(), AnomalyLevel.GREEN) 63 | )) 64 | } 65 | 66 | @Test 67 | fun theDashboard_displaysTodaysReliabilityRate() { 68 | val pageContent = page(http, baseUrl).text 69 | assertThat(pageContent).contains("91.39 %") 70 | assertThat(pageContent).contains("Today") 71 | } 72 | 73 | @Test 74 | fun theDashboard_displaysAllTimeReliabilityRate() { 75 | val pageContent = page(http, baseUrl).text 76 | assertThat(pageContent).contains("77.59 %") 77 | assertThat(pageContent).contains("Last 2 Days") 78 | } 79 | 80 | @Test 81 | fun theDashboard_displaysAnAnamolyForApplicationBoot() { 82 | val pageContent = page(http, baseUrl).text 83 | assertThat(pageContent).contains("Deploy Successful, collecting data") 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/test/kotlin/org/cloudfoundry/loggregator/logmon/LogmonApplicationTests.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.cloudfoundry.loggregator.logmon.pacman.LogTestExecution 5 | import org.cloudfoundry.loggregator.logmon.pacman.Printer 6 | import org.junit.After 7 | import org.junit.Assert.fail 8 | import org.junit.Before 9 | import org.junit.Test 10 | import org.junit.runner.RunWith 11 | import org.springframework.beans.factory.annotation.Autowired 12 | import org.springframework.boot.test.context.SpringBootTest 13 | import org.springframework.boot.test.mock.mockito.MockBean 14 | import org.springframework.test.context.junit4.SpringRunner 15 | import org.springframework.test.context.TestPropertySource 16 | import java.io.ByteArrayOutputStream 17 | import java.io.PrintStream 18 | 19 | @RunWith(SpringRunner::class) 20 | @SpringBootTest 21 | @TestPropertySource(properties = arrayOf( 22 | "logmon.production.log-byte-size=100" 23 | )) 24 | class LogmonApplicationTests { 25 | 26 | @MockBean 27 | private lateinit var logTestExecution: LogTestExecution 28 | 29 | private val outContent = ByteArrayOutputStream() 30 | private var oldStdout: PrintStream? = null 31 | 32 | @Before 33 | fun setUpStreams() { 34 | oldStdout = System.out 35 | System.setOut(PrintStream(outContent)) 36 | } 37 | 38 | @After 39 | fun cleanUpStreams() { 40 | System.setOut(oldStdout) 41 | } 42 | 43 | @Test 44 | fun contextLoads() { 45 | } 46 | 47 | @Autowired 48 | private lateinit var printer: Printer 49 | 50 | @Test 51 | fun theApp_shouldPrintToStandardOutAllTheTimes() { 52 | try { 53 | printer.produce() 54 | printer.produce() 55 | printer.produce() 56 | val lines = outContent.toString().trim().split("\n") 57 | 58 | assertThat(lines.size).isEqualTo(3) 59 | lines.forEach { 60 | // 100 bytes + log4j log metadata 61 | assertThat(it).hasSize(201) 62 | } 63 | } catch (e: InterruptedException) { 64 | e.printStackTrace() 65 | fail() 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/test/kotlin/org/cloudfoundry/loggregator/logmon/StatisticsUiTest.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.cloudfoundry.loggregator.logmon.pacman.LogTestExecution 5 | import org.cloudfoundry.loggregator.logmon.statistics.LogTestExecutionResults 6 | import org.cloudfoundry.loggregator.logmon.statistics.LogTestExecutionsRepo 7 | import org.cloudfoundry.loggregator.logmon.support.page 8 | import org.cloudfoundry.loggregator.logmon.support.text 9 | import org.cloudfoundry.loggregator.logmon.support.xpath 10 | import org.junit.Before 11 | import org.junit.Test 12 | import org.junit.runner.RunWith 13 | import org.mockito.Mockito.`when` 14 | import org.springframework.beans.factory.annotation.Autowired 15 | import org.springframework.boot.context.embedded.LocalServerPort 16 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc 17 | import org.springframework.boot.test.context.SpringBootTest 18 | import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT 19 | import org.springframework.boot.test.mock.mockito.MockBean 20 | import org.springframework.boot.test.web.client.TestRestTemplate 21 | import org.springframework.test.context.ActiveProfiles 22 | import org.springframework.test.context.junit4.SpringRunner 23 | import java.time.Instant 24 | import java.util.regex.Pattern 25 | 26 | @RunWith(SpringRunner::class) 27 | @SpringBootTest(webEnvironment = RANDOM_PORT) 28 | @AutoConfigureMockMvc 29 | @ActiveProfiles("test") 30 | class StatisticsUiTest { 31 | @MockBean 32 | private lateinit var logTestExecution: LogTestExecution 33 | 34 | @MockBean 35 | private lateinit var logTestExecutionsRepo: LogTestExecutionsRepo 36 | 37 | @LocalServerPort 38 | private var port: Int = -1 39 | 40 | private val baseUrl 41 | get() = "http://localhost:$port" 42 | 43 | @Autowired 44 | private lateinit var http: TestRestTemplate 45 | 46 | val now by lazy { Instant.now() } 47 | 48 | @Before 49 | fun setUp() { 50 | `when`(logTestExecutionsRepo.findAll()).thenReturn(listOf( 51 | LogTestExecutionResults(10_000, 9_500, now, 2000.0) 52 | )) 53 | } 54 | 55 | @Test 56 | fun theDashboard_hasALinkToTheListOfLogTestExecutions() { 57 | `when`(logTestExecutionsRepo.findAll()).thenReturn(listOf(LogTestExecutionResults(10_000, 9_500, Instant.now(), 2_000.0))) 58 | val rows = page(http, baseUrl, "/stats").xpath("//table/tbody/tr") 59 | assertThat(rows.length).isEqualTo(1) 60 | .withFailMessage("Expected page to have <%s> rows, had <%s>.", 1, rows.length) 61 | 62 | val cells = page(http, baseUrl, "/stats").xpath("//table/tbody/tr/td") 63 | assertThat(cells.length).isEqualTo(4) 64 | .withFailMessage("Expected page to have <%s> cells, had <%s>.", 4, cells.length) 65 | 66 | val ISO8601 = Pattern.compile("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+Z") 67 | assertThat(cells.item(0).textContent).matches(ISO8601) 68 | assertThat(cells.item(1).textContent).isEqualTo("10000") 69 | assertThat(cells.item(2).textContent).isEqualTo("9500") 70 | assertThat(cells.item(3).text).isEqualTo("10000 logs / 2000.00 ms = 5.00 logs/ms") 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/test/kotlin/org/cloudfoundry/loggregator/logmon/cf/LogStreamerTest.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.cf 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.cloudfoundry.client.CloudFoundryClient 5 | import org.cloudfoundry.client.v2.Metadata 6 | import org.cloudfoundry.client.v2.applications.* 7 | import org.cloudfoundry.client.v2.domains.Domain 8 | import org.cloudfoundry.client.v2.organizations.* 9 | import org.cloudfoundry.client.v2.routes.Route 10 | import org.cloudfoundry.client.v2.spaces.* 11 | import org.cloudfoundry.client.v2.stacks.GetStackResponse 12 | import org.cloudfoundry.client.v2.stacks.StackEntity 13 | import org.cloudfoundry.doppler.* 14 | import org.cloudfoundry.uaa.UaaClient 15 | import org.junit.Before 16 | import org.junit.Test 17 | import org.junit.runner.RunWith 18 | import org.mockito.Answers 19 | import org.mockito.ArgumentMatcher 20 | import org.mockito.Mock 21 | import org.mockito.Mockito.`when` 22 | import org.mockito.Mockito.any 23 | import org.mockito.runners.MockitoJUnitRunner 24 | import org.springframework.boot.test.context.SpringBootTest 25 | import reactor.core.publisher.Flux 26 | import reactor.core.publisher.Mono 27 | import java.net.URI 28 | import java.time.Instant 29 | 30 | @SpringBootTest 31 | @RunWith(MockitoJUnitRunner::class) 32 | class LogStreamerTest { 33 | @Mock(answer = Answers.RETURNS_DEEP_STUBS, name = "Cloud Foundry Client") 34 | private lateinit var cloudFoundryClient: CloudFoundryClient 35 | 36 | @Mock 37 | private lateinit var dopplerClient: DopplerClient 38 | 39 | @Mock 40 | private lateinit var uaaClient: UaaClient 41 | 42 | val cfApplicationEnv = CfApplicationEnv().also { 43 | it.cfApi = URI("https://foo.bar") 44 | it.name = "logmon" 45 | it.spaceId = "some space id" 46 | } 47 | 48 | val subject by lazy { LogStreamer(cloudFoundryClient, dopplerClient, uaaClient, cfApplicationEnv) } 49 | 50 | @Before 51 | fun setUp() { 52 | `when`(cloudFoundryClient.organizations().list(any())) 53 | .thenReturn(Mono.just(organizationList("org-id1"))) 54 | `when`(cloudFoundryClient.organizations().get(any())) 55 | .thenReturn(Mono.just(GetOrganizationResponse.builder() 56 | .entity(OrganizationEntity.builder().name("org name").build()) 57 | .metadata(Metadata.builder().id("org-id1").build()) 58 | .build()) 59 | ) 60 | `when`(cloudFoundryClient.spaces().get(any())).thenReturn(Mono.just(spaceNamed("some space id"))) 61 | `when`(cloudFoundryClient.spaces().list(any())).thenReturn(Mono.just(ListSpacesResponse.builder() 62 | .resource(SpaceResource.builder() 63 | .entity(SpaceEntity.builder().name("space name").organizationId("org-id1").build()) 64 | .metadata(Metadata.builder().id("some space id").build()) 65 | .build()) 66 | .build())) 67 | 68 | `when`(cloudFoundryClient.spaces().listApplications(any())).thenReturn(Mono.just( 69 | ListSpaceApplicationsResponse.builder().resources(listOf( 70 | ApplicationResource.builder() 71 | .metadata(Metadata.builder().id("some app guid").build()) 72 | .entity(ApplicationEntity.builder().stackId("39428934").build()) 73 | .build() 74 | )).build() 75 | )) 76 | 77 | `when`(cloudFoundryClient.applicationsV2().statistics(any())).thenReturn(Mono.just( 78 | ApplicationStatisticsResponse.builder().build() 79 | )) 80 | 81 | `when`(cloudFoundryClient.applicationsV2().summary(any())).thenReturn(Mono.just( 82 | SummaryApplicationResponse.builder() 83 | .id("some app guid") 84 | .diskQuota(39482834) 85 | .instances(9) 86 | .memory(3923) 87 | .name("some app name") 88 | .state("running") 89 | .runningInstances(39) 90 | .stackId("939") 91 | .routes(listOf( 92 | Route.builder() 93 | .domain(Domain.builder().name("localhost:8080").build()) 94 | .host("pez.pivotal.io") 95 | .build() 96 | ) 97 | ).build() 98 | )) 99 | `when`(cloudFoundryClient.applicationsV2().instances(any())).thenReturn(Mono.just( 100 | ApplicationInstancesResponse.builder().instances(mapOf()).build() 101 | )) 102 | `when`(cloudFoundryClient.stacks().get(any())).thenReturn(Mono.just( 103 | GetStackResponse.builder().entity(StackEntity.builder().name("foo").build()).build() 104 | )) 105 | } 106 | 107 | @Test 108 | fun fetchSpaceById_canFetchSpaceById() { 109 | `when`(cloudFoundryClient.organizations().list(any())).thenReturn(Mono.just( 110 | organizationList("org-id1", "org-id2") 111 | )) 112 | `when`(cloudFoundryClient.spaces().get(any())).thenReturn(Mono.just(spaceNamed("some space id"))) 113 | 114 | val space = subject.fetchSpaceById("some space id") 115 | 116 | assertThat(space.metadata.id).isEqualTo("some space id") 117 | } 118 | 119 | @Test 120 | fun fetchApplicationByName_canGetAnApplicationInASpaceByName() { 121 | val application = subject.fetchApplicationByName("some name")!! 122 | 123 | assertThat(application.appName).isEqualTo("some app name") 124 | assertThat(application.spaceName).isEqualTo("some space id") 125 | assertThat(application.orgName).isEqualTo("org name") 126 | } 127 | 128 | @Test 129 | fun logStreamForApplication_providesSubscriptionOfLogMessages() { 130 | `when`(dopplerClient.stream(any())) 131 | .thenReturn(Flux.range(0, 100).map { i -> 132 | Envelope.builder() 133 | .origin("somewhere like home") 134 | .eventType(EventType.LOG_MESSAGE) 135 | .logMessage(LogMessage.builder() 136 | .messageType(MessageType.OUT) 137 | .timestamp(Instant.now().toEpochMilli()) 138 | .message("message: $i") 139 | .build()) 140 | .build() 141 | }) 142 | val stream = subject.logStreamForApplication(LogStreamer.Application("org", "space", "app")) 143 | 144 | val messages = stream.collectList().block() 145 | assertThat(messages).hasSize(100) 146 | } 147 | 148 | private fun spaceNamed(spaceId: String) = GetSpaceResponse.builder() 149 | .entity(SpaceEntity.builder().name(spaceId).organizationId("some org id").build()) 150 | .metadata(Metadata.builder().id(spaceId).build()) 151 | .build() 152 | 153 | private fun organizationList(vararg ids: String): ListOrganizationsResponse? { 154 | return ListOrganizationsResponse.builder().resources( 155 | ids.map { OrganizationResource.builder().metadata(Metadata.builder().id(it).build()).build() } 156 | ).build() 157 | } 158 | 159 | private class CfRequestMatcher(val func: (arg: ListOrganizationsRequest) -> Boolean) : ArgumentMatcher() { 160 | override fun matches(argument: Any?): Boolean { 161 | if (argument == null) return false 162 | return func(argument as ListOrganizationsRequest) 163 | } 164 | } 165 | } 166 | 167 | -------------------------------------------------------------------------------- /src/test/kotlin/org/cloudfoundry/loggregator/logmon/pacman/LogProductionTaskTest.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.pacman 2 | 3 | import org.cloudfoundry.loggregator.logmon.logs.LogProducer 4 | import org.junit.Test 5 | import org.junit.runner.RunWith 6 | import org.mockito.Mock 7 | import org.mockito.Mockito.times 8 | import org.mockito.Mockito.verify 9 | import org.mockito.runners.MockitoJUnitRunner 10 | import org.springframework.boot.actuate.metrics.repository.MetricRepository 11 | import reactor.test.StepVerifier 12 | import java.time.Duration 13 | 14 | @RunWith(MockitoJUnitRunner::class) 15 | class LogProductionTaskTest { 16 | @Mock 17 | private lateinit var logProducer: LogProducer 18 | @Mock 19 | private lateinit var metricRepository: MetricRepository 20 | 21 | @Test 22 | fun production_createsALogPeriodically() { 23 | val productionTask = LogProductionTask(logProducer, metricRepository, 1000, 2) 24 | 25 | StepVerifier.withVirtualTime { productionTask.get() } 26 | .expectSubscription() 27 | 28 | .thenAwait(Duration.ofMillis(1)) 29 | .consumeNextWith { verify(logProducer).produce() } 30 | 31 | .expectNoEvent(Duration.ofMillis(499)) 32 | .thenAwait(Duration.ofMillis(1)) 33 | 34 | .consumeNextWith { verify(logProducer, times(2)).produce() } 35 | .verifyComplete() 36 | } 37 | 38 | @Test 39 | fun production_canHandleSubMillisecondResolution() { 40 | val productionTask = LogProductionTask(logProducer, metricRepository, 1000, 2001) 41 | 42 | StepVerifier.withVirtualTime { productionTask.get() } 43 | .expectSubscription() 44 | .thenAwait(Duration.ofMillis(1)) 45 | .consumeNextWith { verify(logProducer, times(3)).produce() } 46 | .thenAwait(Duration.ofMillis(1)) 47 | .consumeNextWith { verify(logProducer, times(5)).produce() } 48 | .thenAwait(Duration.ofMillis(1)) 49 | .consumeNextWith { verify(logProducer, times(7)).produce() } 50 | .thenAwait(Duration.ofMillis(997)) 51 | .thenConsumeWhile { true } 52 | .verifyComplete() 53 | } 54 | 55 | @Test 56 | fun production_canHandleSubMillisecondResolutionWithMorePellets() { 57 | val productionTask = LogProductionTask(logProducer, metricRepository, 10000, 10001) 58 | 59 | StepVerifier.withVirtualTime { productionTask.get() } 60 | .expectSubscription() 61 | .thenAwait(Duration.ofMillis(1)) 62 | .consumeNextWith { verify(logProducer, times(2)).produce() } 63 | .thenAwait(Duration.ofMillis(1)) 64 | .consumeNextWith { verify(logProducer, times(3)).produce() } 65 | .thenAwait(Duration.ofMillis(1)) 66 | .consumeNextWith { verify(logProducer, times(4)).produce() } 67 | .thenAwait(Duration.ofMillis(9997)) 68 | .thenConsumeWhile { true } 69 | .verifyComplete() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/test/kotlin/org/cloudfoundry/loggregator/logmon/pacman/LogSinkTest.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.pacman 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.cloudfoundry.doppler.LogMessage 5 | import org.cloudfoundry.doppler.MessageType 6 | import org.cloudfoundry.loggregator.logmon.cf.CfApplicationEnv 7 | import org.cloudfoundry.loggregator.logmon.cf.LogStreamer 8 | import org.cloudfoundry.loggregator.logmon.cf.LogStreamer.Application 9 | import org.cloudfoundry.loggregator.logmon.statistics.LOGS_CONSUMED 10 | import org.cloudfoundry.loggregator.logmon.support.any 11 | import org.junit.Before 12 | import org.junit.Test 13 | import org.junit.runner.RunWith 14 | import org.mockito.Mock 15 | import org.mockito.Mockito.* 16 | import org.mockito.runners.MockitoJUnitRunner 17 | import org.springframework.boot.actuate.metrics.CounterService 18 | import reactor.core.publisher.Flux 19 | import reactor.core.publisher.Mono 20 | import reactor.test.StepVerifier 21 | import reactor.test.publisher.TestPublisher 22 | import java.net.URI 23 | import java.time.Duration 24 | import java.time.Instant 25 | 26 | 27 | @RunWith(MockitoJUnitRunner::class) 28 | class LogSinkTest { 29 | @Mock 30 | private lateinit var logStreamer: LogStreamer 31 | 32 | @Mock 33 | private lateinit var counterService: CounterService 34 | 35 | private val appEnv = CfApplicationEnv().also { 36 | it.spaceId = "Middle Earth" 37 | it.name = "logmon" 38 | it.cfApi = URI("https://example.com") 39 | } 40 | 41 | @Before 42 | fun setUp() { 43 | `when`(logStreamer.fetchApplicationByName(any())).thenReturn(Application("foo", "bar", "baz")) 44 | } 45 | 46 | @Test 47 | fun consume_returnsNumberOfValidLogsConsumed() { 48 | `when`(logStreamer.logStreamForApplication(any())).thenReturn(Flux.just( 49 | message("$VALID_MESSAGE_PATTERN 1"), 50 | message("$VALID_MESSAGE_PATTERN 2"), 51 | message("NOT VALID"), 52 | message("$VALID_MESSAGE_PATTERN 3") 53 | )) 54 | 55 | val sink = LogSink(appEnv, logStreamer, counterService) 56 | assertThat(sink.consume(Mono.empty()).block()).isEqualTo(3) 57 | } 58 | 59 | @Test 60 | fun consume_runsForTenSecondsAfterTheProductionCompletes() { 61 | val productionCompletePublisher = TestPublisher.create() 62 | val logGenerator = TestPublisher.create() 63 | StepVerifier.withVirtualTime { 64 | `when`(logStreamer.logStreamForApplication(any())).thenReturn(logGenerator.flux()) 65 | LogSink(appEnv, logStreamer, counterService).consume(productionCompletePublisher.mono()) 66 | } 67 | .thenAwait(Duration.ofMillis(10_000)) 68 | .then { repeat(5) { logGenerator.next(message("Printer - $it")) } } 69 | .expectNoEvent(Duration.ofMillis(10_000)) 70 | .then { productionCompletePublisher.emit() } 71 | .expectNoEvent(Duration.ofMillis(9_999)) 72 | .thenAwait(Duration.ofMillis(1)) 73 | .expectNextMatches { count -> count == 5L } 74 | .verifyComplete() 75 | } 76 | 77 | @Test 78 | fun consume_whenLogStreamForApplicationThrows_continuesWithNANLogs() { 79 | val productionCompletePublisher = TestPublisher.create() 80 | StepVerifier.withVirtualTime { 81 | `when`(logStreamer.logStreamForApplication(any())).thenReturn(Flux.error(NullPointerException())) 82 | LogSink(appEnv, logStreamer, counterService).consume(productionCompletePublisher.mono()) 83 | } 84 | .expectNextMatches { count -> count == -1L } 85 | .verifyComplete() 86 | } 87 | 88 | private fun message(text: String): LogMessage = LogMessage.builder() 89 | .message(text) 90 | .messageType(MessageType.OUT) 91 | .timestamp(Instant.now().toEpochMilli()) 92 | .build() 93 | } 94 | -------------------------------------------------------------------------------- /src/test/kotlin/org/cloudfoundry/loggregator/logmon/pacman/LogTestExecutionSpringTest.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.pacman; 2 | 3 | import org.cloudfoundry.loggregator.logmon.statistics.LogTestExecutionsRepo; 4 | import org.cloudfoundry.loggregator.logmon.support.any 5 | import org.junit.Before 6 | import org.junit.Test 7 | import org.junit.runner.RunWith; 8 | import org.mockito.Mockito.* 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.actuate.metrics.CounterService; 11 | import org.springframework.boot.actuate.metrics.Metric 12 | import org.springframework.boot.actuate.metrics.repository.MetricRepository; 13 | import org.springframework.boot.test.context.SpringBootTest; 14 | import org.springframework.boot.test.mock.mockito.MockBean; 15 | import org.springframework.scheduling.TaskScheduler 16 | import org.springframework.test.context.ActiveProfiles 17 | import org.springframework.test.context.junit4.SpringRunner; 18 | import reactor.core.publisher.Mono 19 | import java.time.Duration 20 | import java.util.* 21 | 22 | @RunWith(SpringRunner::class) 23 | @SpringBootTest 24 | abstract class LogTestExecutionSpringTest { 25 | @MockBean 26 | protected lateinit var printer: Printer 27 | 28 | @MockBean 29 | protected lateinit var logSink: LogSink 30 | 31 | @MockBean 32 | protected lateinit var counterService: CounterService 33 | 34 | @MockBean 35 | protected lateinit var metricRepository: MetricRepository 36 | 37 | @MockBean 38 | protected lateinit var logTestExecutionsRepo: LogTestExecutionsRepo 39 | 40 | @MockBean 41 | private lateinit var taskScheduler: TaskScheduler 42 | 43 | @Autowired 44 | protected lateinit var logTest: LogTestExecution 45 | 46 | protected open var numPellets: Int = -1 47 | 48 | @Before 49 | fun setUp() { 50 | `when`(logSink.consume(any>())).thenReturn( 51 | Mono.delay(Duration.ofMillis(1500)).then(Mono.just(numPellets.toLong())) 52 | ) 53 | `when`(metricRepository.findOne(anyString())).thenReturn(Metric("", 5, Date())) 54 | } 55 | } 56 | 57 | @SpringBootTest(properties = arrayOf( 58 | "logmon.production.log-cycles=2", 59 | "logmon.production.log-duration-millis=1000", 60 | "logmon.production.initial-delay-millis=0" 61 | )) 62 | @ActiveProfiles("test") 63 | class QuietAppTest : LogTestExecutionSpringTest() { 64 | override var numPellets: Int = 2 65 | 66 | @Test 67 | fun runTest_shouldUseTheCorrectProfile() { 68 | logTest.runTest() 69 | 70 | verify(printer, times(2)).produce() 71 | } 72 | } 73 | 74 | @SpringBootTest(properties = arrayOf( 75 | "logmon.production.log-cycles=1000", 76 | "logmon.production.log-duration-millis=1000", 77 | "logmon.production.initial-delay-millis=0" 78 | )) 79 | class NormalAppTest : LogTestExecutionSpringTest() { 80 | override var numPellets: Int = 1000 81 | 82 | @Test 83 | fun runTest_shouldUseTheCorrectProfile() { 84 | logTest.runTest() 85 | 86 | verify(printer, times(1000)).produce() 87 | } 88 | } 89 | 90 | @SpringBootTest(properties = arrayOf( 91 | "logmon.production.log-cycles=5000", 92 | "logmon.production.log-duration-millis=1000", 93 | "logmon.production.initial-delay-millis=0" 94 | )) 95 | class NoisyAppTest : LogTestExecutionSpringTest() { 96 | override var numPellets: Int = 5000 97 | 98 | @Test 99 | fun runTest_shouldUseTheCorrectProfile() { 100 | logTest.runTest() 101 | 102 | verify(printer, times(5000)).produce() 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/test/kotlin/org/cloudfoundry/loggregator/logmon/pacman/LogTestExecutionTest.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.pacman 2 | 3 | import com.nhaarman.mockito_kotlin.argumentCaptor 4 | import org.assertj.core.api.Assertions.assertThat 5 | import org.cloudfoundry.loggregator.logmon.anomalies.AnomalyStateMachine 6 | import org.cloudfoundry.loggregator.logmon.statistics.* 7 | import org.cloudfoundry.loggregator.logmon.support.any 8 | import org.junit.Before 9 | import org.junit.Test 10 | import org.junit.runner.RunWith 11 | import org.mockito.InjectMocks 12 | import org.mockito.Mock 13 | import org.mockito.Mockito.* 14 | import org.mockito.runners.MockitoJUnitRunner 15 | import org.springframework.boot.actuate.metrics.CounterService 16 | import org.springframework.boot.actuate.metrics.Metric 17 | import org.springframework.boot.actuate.metrics.repository.MetricRepository 18 | import reactor.core.publisher.Mono 19 | import java.util.* 20 | 21 | @RunWith(MockitoJUnitRunner::class) 22 | class LogTestExecutionTest { 23 | @Mock 24 | private lateinit var printer: Printer 25 | 26 | @Mock 27 | private lateinit var logSink: LogSink 28 | 29 | @Mock 30 | private lateinit var counterService: CounterService 31 | 32 | @Mock 33 | private lateinit var metricRepository: MetricRepository 34 | 35 | @Mock 36 | private lateinit var logTestExecutionsRepo: LogTestExecutionsRepo 37 | 38 | @Mock 39 | private lateinit var stateMachine: AnomalyStateMachine 40 | 41 | @InjectMocks 42 | private lateinit var logTest: LogTestExecution 43 | 44 | @Before 45 | fun setUp() { 46 | `when`(logSink.consume(any>())).thenReturn(Mono.just(10_000)) 47 | `when`(metricRepository.findOne(anyString())).thenReturn(Metric("metric", 10_000, Date())) 48 | } 49 | 50 | @Test 51 | fun runTest_shouldClearAllLogmonMetrics() { 52 | logTest.runTest() 53 | verify(counterService).reset(LOGS_PRODUCED) 54 | verify(counterService).reset(LOGS_CONSUMED) 55 | } 56 | 57 | @Test 58 | fun runTest_shouldSetTheLastExecutionTime() { 59 | logTest.runTest() 60 | 61 | argumentCaptor>().apply { 62 | verify(metricRepository, atLeastOnce()).set(capture()) 63 | 64 | assertThat(firstValue.name).isEqualTo(LAST_EXECUTION_TIME) 65 | assertThat(firstValue.value).isEqualTo(0) 66 | assertThat(firstValue.timestamp).isInstanceOf(Date::class.java) 67 | } 68 | } 69 | 70 | @Test 71 | fun runTest_shouldAddTheExecutionResultsToTheRepoAndRecalculatesTheState() { 72 | logTest.runTest() 73 | 74 | tryWithTimeout(5000) { 75 | try { 76 | verify(stateMachine).recalculate() 77 | true 78 | } catch(e: Exception) { 79 | false 80 | } 81 | } 82 | 83 | argumentCaptor().apply { 84 | verify(logTestExecutionsRepo).save(capture()) 85 | 86 | assertThat(firstValue.logsProduced).isEqualTo(10_000) 87 | assertThat(firstValue.logsConsumed).isEqualTo(10_000) 88 | assertThat(firstValue.productionDuration).isEqualTo(10_000.0) 89 | } 90 | } 91 | 92 | @Test 93 | fun runTest_whenTheTestFinishes_shouldSetLOGS_CONSUMED() { 94 | `when`(logSink.consume(any>())).thenReturn(Mono.just(999)) 95 | logTest.runTest() 96 | 97 | argumentCaptor>().apply { 98 | verify(metricRepository, atLeastOnce()).set(capture()) 99 | 100 | val consumedUpdate = allValues.find { it.name == "counter.$LOGS_CONSUMED" }!! 101 | 102 | assertThat(consumedUpdate.value).isEqualTo(999L) 103 | } 104 | } 105 | 106 | private fun tryWithTimeout(timeToWait: Int, waitDoneCheck: () -> Boolean) { 107 | var tries = 0 108 | while (!waitDoneCheck.invoke()) { 109 | if (tries > timeToWait / 100) throw Exception("Thing never happened") 110 | tries++ 111 | Thread.sleep(100) 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/test/kotlin/org/cloudfoundry/loggregator/logmon/pacman/PacmanTest.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.pacman 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.assertj.core.api.Assertions.fail 5 | import org.cloudfoundry.loggregator.logmon.logs.LogConsumer 6 | import org.cloudfoundry.loggregator.logmon.logs.LogProducer 7 | import org.cloudfoundry.loggregator.logmon.statistics.LOG_WRITE_TIME_MILLIS 8 | import org.cloudfoundry.loggregator.logmon.support.any 9 | import org.junit.Before 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | import org.mockito.ArgumentCaptor 13 | import org.mockito.Mock 14 | import org.mockito.Mockito.* 15 | import org.mockito.runners.MockitoJUnitRunner 16 | import org.springframework.boot.actuate.metrics.Metric 17 | import org.springframework.boot.actuate.metrics.repository.MetricRepository 18 | import reactor.core.publisher.Mono 19 | import reactor.test.StepVerifier 20 | import java.time.Duration 21 | 22 | @RunWith(MockitoJUnitRunner::class) 23 | class PacmanTest { 24 | @Mock 25 | private lateinit var logConsumer: LogConsumer 26 | 27 | @Mock 28 | private lateinit var logProducer: LogProducer 29 | 30 | @Mock 31 | private lateinit var metricRepository: MetricRepository 32 | 33 | private val pacman: Pacman by lazy { Pacman(logProducer, logConsumer, metricRepository, 1000, 20, 2500) } 34 | 35 | @Before 36 | fun setUp() { 37 | `when`(logConsumer.consume(any())).thenReturn(Mono.just(20)) 38 | } 39 | 40 | @Test 41 | fun pacman_beginsConsumptionAndThenStartsProduction() { 42 | StepVerifier.withVirtualTime { pacman.begin() } 43 | .thenAwait(Duration.ofMillis(2499)) 44 | .then { verifyZeroInteractions(logProducer) } 45 | .then { verify(logConsumer).consume(any()) } 46 | .thenAwait(Duration.ofMillis(1)) 47 | .thenAwait(Duration.ofMillis(1000)) 48 | .then { verify(logProducer, times(20)).produce() } 49 | .expectNext(20) 50 | .verifyComplete() 51 | } 52 | 53 | @Test 54 | fun pacman_reportsLogsConsumed() { 55 | try { 56 | StepVerifier.withVirtualTime { pacman.begin() } 57 | .thenAwait(Duration.ofSeconds(5)) 58 | .consumeNextWith { } 59 | .verifyComplete() 60 | } catch(e: Exception) { 61 | fail("Something else went wrong: $e") 62 | } 63 | } 64 | 65 | @Test 66 | fun pacman_shouldCaptureTheLogProductionFinishTime() { 67 | val captor = ArgumentCaptor.forClass(Metric::class.java) 68 | StepVerifier.withVirtualTime { pacman.begin() } 69 | .then { verifyZeroInteractions(metricRepository) } 70 | .thenAwait(Duration.ofMillis(2500)) 71 | .thenAwait(Duration.ofMillis(1000)) 72 | .then { verify(metricRepository).set(captor.capture()) } 73 | .consumeNextWith { } 74 | .verifyComplete() 75 | 76 | assertThat(captor.value.name).isEqualTo("counter.$LOG_WRITE_TIME_MILLIS") 77 | assertThat(captor.value.value.toDouble()).isEqualTo(1000.0) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/test/kotlin/org/cloudfoundry/loggregator/logmon/statistics/AnomalyStateMachineTest.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.statistics 2 | 3 | import com.nhaarman.mockito_kotlin.verifyZeroInteractions 4 | import org.cloudfoundry.loggregator.logmon.anomalies.AnomalyLevel 5 | import org.cloudfoundry.loggregator.logmon.anomalies.AnomalyRepo 6 | import org.cloudfoundry.loggregator.logmon.anomalies.AnomalyStateMachine 7 | import org.junit.Test 8 | import org.junit.runner.RunWith 9 | import org.mockito.Mock 10 | import org.mockito.Mockito.* 11 | import org.mockito.runners.MockitoJUnitRunner 12 | import java.time.Instant 13 | 14 | @RunWith(MockitoJUnitRunner::class) 15 | class AnomalyStateMachineTest { 16 | @Mock 17 | private lateinit var logTestExecutionsRepo: LogTestExecutionsRepo 18 | 19 | @Mock 20 | private lateinit var anomalyRepo: AnomalyRepo 21 | 22 | private val statistics = StatisticsPresenter() 23 | 24 | private val anomalyStateMachine: AnomalyStateMachine by lazy { 25 | AnomalyStateMachine(anomalyRepo, logTestExecutionsRepo, statistics) 26 | } 27 | 28 | @Test 29 | fun recalculate_whenTheLastNTestsAreAboveTheGreenThresholdAndThereIsNoPriorState_doesNothing() { 30 | `when`(logTestExecutionsRepo.findAll()).thenReturn(listOf( 31 | LogTestExecutionResults(1000, 1000, Instant.now(), 0.0), 32 | LogTestExecutionResults(1000, 1000, Instant.now(), 0.0), 33 | LogTestExecutionResults(1000, 1000, Instant.now(), 0.0), 34 | LogTestExecutionResults(1000, 1000, Instant.now(), 0.0), 35 | LogTestExecutionResults(1000, 1000, Instant.now(), 0.0), 36 | LogTestExecutionResults(1000, 1000, Instant.now(), 0.0) 37 | )) 38 | 39 | anomalyStateMachine.recalculate() 40 | 41 | verifyZeroInteractions(anomalyRepo) 42 | } 43 | 44 | @Test 45 | fun recalculate_whenTheLastNTestsFallBelowRedThreshold_createsAnAnomaly() { 46 | `when`(logTestExecutionsRepo.findAll()).thenReturn(listOf( 47 | LogTestExecutionResults(1000, 1000, Instant.now(), 0.0), 48 | LogTestExecutionResults(1000, 890, Instant.now(), 0.0), 49 | LogTestExecutionResults(1000, 890, Instant.now(), 0.0), 50 | LogTestExecutionResults(1000, 890, Instant.now(), 0.0), 51 | LogTestExecutionResults(1000, 890, Instant.now(), 0.0), 52 | LogTestExecutionResults(1000, 890, Instant.now(), 0.0) 53 | )) 54 | 55 | anomalyStateMachine.recalculate() 56 | 57 | verify(anomalyRepo).save( 58 | "The average reliability rate for the last 5 tests is 89%. " + 59 | "Click \"Review Data\" in the chart to see more info on the logs.", 60 | AnomalyLevel.RED 61 | ) 62 | } 63 | 64 | @Test 65 | fun recalculate_whenTheStateIsAlreadyRed_doesNotCreateANewAnomaly() { 66 | `when`(logTestExecutionsRepo.findAll()).thenReturn(listOf( 67 | LogTestExecutionResults(1000, 1000, Instant.now(), 0.0), 68 | LogTestExecutionResults(1000, 890, Instant.now(), 0.0), 69 | LogTestExecutionResults(1000, 890, Instant.now(), 0.0), 70 | LogTestExecutionResults(1000, 890, Instant.now(), 0.0), 71 | LogTestExecutionResults(1000, 890, Instant.now(), 0.0), 72 | LogTestExecutionResults(1000, 890, Instant.now(), 0.0) 73 | )) 74 | 75 | anomalyStateMachine.recalculate() 76 | anomalyStateMachine.recalculate() 77 | 78 | verify(anomalyRepo, times(1)).save( 79 | "The average reliability rate for the last 5 tests is 89%. " + 80 | "Click \"Review Data\" in the chart to see more info on the logs.", 81 | AnomalyLevel.RED 82 | ) 83 | } 84 | 85 | @Test 86 | fun recalculate_whenThereAreFewerThanNTests_doesNothing() { 87 | `when`(logTestExecutionsRepo.findAll()).thenReturn(listOf( 88 | LogTestExecutionResults(1000, 890, Instant.now(), 0.0), 89 | LogTestExecutionResults(1000, 890, Instant.now(), 0.0), 90 | LogTestExecutionResults(1000, 890, Instant.now(), 0.0), 91 | LogTestExecutionResults(1000, 890, Instant.now(), 0.0) 92 | )) 93 | 94 | anomalyStateMachine.recalculate() 95 | 96 | verifyZeroInteractions(anomalyRepo) 97 | } 98 | 99 | @Test 100 | fun recalculate_whenTheLastNTestsCrossIntoYellowThreshold_createsAYellowAnomaly() { 101 | `when`(logTestExecutionsRepo.findAll()).thenReturn(listOf( 102 | LogTestExecutionResults(1000, 1000, Instant.now(), 0.0), 103 | LogTestExecutionResults(1000, 950, Instant.now(), 0.0), 104 | LogTestExecutionResults(1000, 950, Instant.now(), 0.0), 105 | LogTestExecutionResults(1000, 950, Instant.now(), 0.0), 106 | LogTestExecutionResults(1000, 950, Instant.now(), 0.0), 107 | LogTestExecutionResults(1000, 950, Instant.now(), 0.0) 108 | )) 109 | 110 | anomalyStateMachine.recalculate() 111 | 112 | verify(anomalyRepo, times(1)).save( 113 | "Reliability Rate 95%", 114 | AnomalyLevel.YELLOW 115 | ) 116 | } 117 | 118 | @Test 119 | fun recalculate_whenTheLastNTestsCrossAboveTheGreenThreshold_createsAnAnomaly() { 120 | `when`(logTestExecutionsRepo.findAll()).thenReturn( 121 | listOf( 122 | LogTestExecutionResults(1000, 1000, Instant.now(), 0.0), 123 | LogTestExecutionResults(1000, 950, Instant.now(), 0.0), 124 | LogTestExecutionResults(1000, 950, Instant.now(), 0.0), 125 | LogTestExecutionResults(1000, 950, Instant.now(), 0.0), 126 | LogTestExecutionResults(1000, 950, Instant.now(), 0.0), 127 | LogTestExecutionResults(1000, 950, Instant.now(), 0.0) 128 | ), 129 | listOf( 130 | LogTestExecutionResults(1000, 1000, Instant.now(), 0.0), 131 | LogTestExecutionResults(1000, 1000, Instant.now(), 0.0), 132 | LogTestExecutionResults(1000, 1000, Instant.now(), 0.0), 133 | LogTestExecutionResults(1000, 1000, Instant.now(), 0.0), 134 | LogTestExecutionResults(1000, 1000, Instant.now(), 0.0), 135 | LogTestExecutionResults(1000, 1000, Instant.now(), 0.0) 136 | ) 137 | ) 138 | 139 | anomalyStateMachine.recalculate() 140 | anomalyStateMachine.recalculate() 141 | 142 | val inOrder = inOrder(anomalyRepo) 143 | inOrder.verify(anomalyRepo, times(1)).save( 144 | "Reliability Rate 95%", 145 | AnomalyLevel.YELLOW 146 | ) 147 | inOrder.verify(anomalyRepo, times(1)).save( 148 | "Reliability Rate 100%", 149 | AnomalyLevel.GREEN 150 | ) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/test/kotlin/org/cloudfoundry/loggregator/logmon/statistics/StatisticsPresenterTest.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.statistics 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.assertj.core.api.Assertions.within 5 | import org.junit.Test 6 | import org.junit.runner.RunWith 7 | import org.junit.runners.JUnit4 8 | import java.time.Duration 9 | import java.time.Instant 10 | 11 | @RunWith(JUnit4::class) 12 | class StatisticsPresenterTest { 13 | private val statistics = StatisticsPresenter() 14 | 15 | @Test 16 | fun reliability_returnsAverageReliability() { 17 | val results = listOf( 18 | LogTestExecutionResults(10, 9, Instant.now(), 0.0), 19 | LogTestExecutionResults(10, 8, Instant.now(), 0.0), 20 | LogTestExecutionResults(10, 7, Instant.now(), 0.0), 21 | LogTestExecutionResults(10, 6, Instant.now(), 0.0) 22 | ) 23 | 24 | assertThat(statistics.reliability(results)).isCloseTo(.75, within(0.0000001)) 25 | } 26 | 27 | @Test 28 | fun reliability_handlesFailedConsumptionRecords() { 29 | val results = listOf( 30 | LogTestExecutionResults(10, -1, Instant.now(), 0.0), 31 | LogTestExecutionResults(10, 8, Instant.now(), 0.0) 32 | ) 33 | 34 | assertThat(statistics.reliability(results)).isCloseTo(.80, within(0.0000001)) 35 | } 36 | 37 | @Test 38 | fun runTime_returnsZeroWithAnEmptyList() { 39 | val results = emptyList() 40 | assertThat(statistics.runTime(results)).isEqualTo(Duration.ZERO) 41 | } 42 | 43 | @Test 44 | fun runTime_returnsTheTimeBetweenTheFirstAndLastTest() { 45 | val results = listOf( 46 | LogTestExecutionResults(10, 9, Instant.parse("2014-02-02T00:00:00Z"), 0.0), 47 | LogTestExecutionResults(10, 9, Instant.parse("2014-02-04T00:00:00Z"), 0.0) 48 | ) 49 | assertThat(statistics.runTime(results)).isEqualTo(Duration.ofDays(2)) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/test/kotlin/org/cloudfoundry/loggregator/logmon/support/htmlHelpers.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.support 2 | 3 | import org.springframework.boot.test.web.client.TestRestTemplate 4 | import org.springframework.http.MediaType 5 | import org.springframework.http.RequestEntity 6 | import org.w3c.dom.Document 7 | import org.w3c.dom.Node 8 | import java.net.URI 9 | 10 | fun page(http: TestRestTemplate, baseUrl: String, path: String = "/"): Document { 11 | val request = RequestEntity.get(URI(baseUrl + path)) 12 | .header("Authorization", "Basic YWRtaW46cGFzc3dvcmQ=") 13 | .accept(MediaType.TEXT_HTML) 14 | .build() 15 | return http.exchange(request, String::class.java).body.getHtml() 16 | } 17 | 18 | val Document.text: String 19 | get() = xpath("//body").item(0).text 20 | 21 | val Node.text: String 22 | get() = textContent.trim().replace(Regex("\\s+"), " ") 23 | -------------------------------------------------------------------------------- /src/test/kotlin/org/cloudfoundry/loggregator/logmon/support/kotlinAny.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.support 2 | 3 | import org.mockito.Mockito 4 | 5 | fun any(): T { 6 | Mockito.any() 7 | return uninitialized() 8 | } 9 | 10 | fun uninitialized(): T = null as T 11 | -------------------------------------------------------------------------------- /src/test/kotlin/org/cloudfoundry/loggregator/logmon/support/xmlTestingExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.cloudfoundry.loggregator.logmon.support 2 | 3 | import org.w3c.dom.Document 4 | import org.w3c.dom.NodeList 5 | import javax.xml.parsers.DocumentBuilderFactory 6 | import javax.xml.xpath.XPathConstants 7 | import javax.xml.xpath.XPathFactory 8 | 9 | 10 | fun String.getHtml(): Document { 11 | val domFactory = DocumentBuilderFactory.newInstance() 12 | domFactory.isNamespaceAware = true 13 | val builder = domFactory.newDocumentBuilder() 14 | return builder.parse(this.byteInputStream()) 15 | } 16 | 17 | fun Document.xpath(path: String): NodeList { 18 | val xpath = XPathFactory.newInstance().newXPath() 19 | val expr = xpath.compile(path) 20 | 21 | val result = expr.evaluate(this, XPathConstants.NODESET) 22 | return result as NodeList 23 | } 24 | --------------------------------------------------------------------------------