├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rubocop.yml ├── .streerc ├── CHANGELOG.md ├── Gemfile ├── Guardfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── assets ├── images │ ├── Icon-144_rounded.png │ ├── Icon-144_square.png │ ├── icon_144x144.png │ └── icon_64x64.png ├── javascript │ └── .gitkeep └── stylesheets │ └── .gitkeep ├── bin ├── guard └── rake ├── build_client_app.sh ├── client-app ├── .editorconfig ├── .ember-cli ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .template-lintrc.js ├── .watchmanconfig ├── README.md ├── app │ ├── app.js │ ├── components │ │ ├── actions-menu.js │ │ ├── back-to-site-link.js │ │ ├── back-trace.js │ │ ├── env-tab.js │ │ ├── message-info.js │ │ ├── message-row.js │ │ ├── page-nav.js │ │ ├── panel-resizer.js │ │ ├── patterns-list.js │ │ ├── tab-contents.js │ │ ├── tabbed-section.js │ │ └── time-formatter.js │ ├── controllers │ │ ├── index.js │ │ └── show.js │ ├── helpers │ │ ├── logster-url.js │ │ └── or.js │ ├── index.html │ ├── initializers │ │ └── app-init.js │ ├── lib │ │ ├── decorators.js │ │ ├── preload.js │ │ └── utilities.js │ ├── models │ │ ├── group.js │ │ ├── message-collection.js │ │ ├── message.js │ │ └── pattern-item.js │ ├── resolver.js │ ├── router.js │ ├── routes │ │ ├── index.js │ │ ├── settings.js │ │ └── show.js │ ├── services │ │ └── events.js │ ├── styles │ │ └── app.css │ └── templates │ │ ├── application.hbs │ │ ├── components │ │ ├── actions-menu.hbs │ │ ├── back-to-site-link.hbs │ │ ├── back-trace.hbs │ │ ├── env-tab.hbs │ │ ├── message-info.hbs │ │ ├── message-row.hbs │ │ ├── page-nav.hbs │ │ ├── panel-resizer.hbs │ │ ├── patterns-list.hbs │ │ ├── tabbed-section.hbs │ │ └── time-formatter.hbs │ │ ├── index.hbs │ │ ├── settings.hbs │ │ └── show.hbs ├── config │ ├── ember-cli-update.json │ ├── environment.js │ ├── icons.js │ ├── optional-features.json │ └── targets.js ├── ember-cli-build.js ├── package.json ├── preload-json-manager.rb ├── public │ └── assets │ │ └── images │ │ ├── icon_144x144.png │ │ └── icon_64x64.png ├── testem.js ├── tests │ ├── index.html │ ├── integration │ │ └── components │ │ │ ├── back-to-site-link-test.js │ │ │ ├── back-trace-test.js │ │ │ ├── env-tab-test.js │ │ │ ├── message-info-test.js │ │ │ └── patterns-list-test.js │ ├── test-helper.js │ └── unit │ │ ├── controllers │ │ ├── index-test.js │ │ └── show-test.js │ │ └── routes │ │ ├── index-test.js │ │ └── show-test.js └── yarn.lock ├── gemfiles ├── rails_6.1.gemfile ├── rails_7.0.gemfile ├── rails_7.1.gemfile └── rails_7.2.gemfile ├── lib ├── examples │ └── sidekiq_logster_reporter.rb ├── logster.rb └── logster │ ├── base_store.rb │ ├── cache.rb │ ├── configuration.rb │ ├── defer_logger.rb │ ├── group.rb │ ├── grouping_pattern.rb │ ├── ignore_pattern.rb │ ├── logger.rb │ ├── message.rb │ ├── middleware │ ├── debug_exceptions.rb │ ├── reporter.rb │ └── viewer.rb │ ├── pattern.rb │ ├── rails │ └── railtie.rb │ ├── redis_rate_limiter.rb │ ├── redis_store.rb │ ├── scheduler.rb │ ├── suppression_pattern.rb │ ├── version.rb │ └── web.rb ├── logster.gemspec ├── test ├── dummy │ └── config │ │ ├── application.rb │ │ ├── boot.rb │ │ └── environment.rb ├── examples │ └── test_sidekiq_reporter_example.rb ├── fake_data │ ├── Gemfile │ └── generate.rb ├── logster │ ├── middleware │ │ ├── test_reporter.rb │ │ └── test_viewer.rb │ ├── test_base_store.rb │ ├── test_cache.rb │ ├── test_defer_logger.rb │ ├── test_group.rb │ ├── test_ignore_pattern.rb │ ├── test_logger.rb │ ├── test_message.rb │ ├── test_pattern.rb │ ├── test_railtie.rb │ ├── test_redis_rate_limiter.rb │ └── test_redis_store.rb └── test_helper.rb ├── vendor └── assets │ └── javascripts │ └── logster.js.erb └── website ├── Gemfile ├── Gemfile.lock ├── README.md ├── config.ru ├── data └── data.json ├── docker_container ├── logster.yml └── update_logster ├── images ├── icon_144x144.ai ├── icon_64x64.ai ├── logo-logster-cropped-small.png ├── logo-logster-cropped.png ├── logo_logster_CMYK.eps ├── logo_logster_RGB.eps ├── logo_logster_RGB.jpg └── logster-screenshot.png ├── sample.rb └── scripts └── persist_logs.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | backend: 11 | runs-on: ubuntu-latest 12 | 13 | name: "Ruby ${{ matrix.ruby }} - Rails ${{ matrix.rails }}" 14 | 15 | services: 16 | redis: 17 | image: redis 18 | ports: 19 | - 6379:6379 20 | 21 | strategy: 22 | matrix: 23 | ruby: ["3.2", "3.3", "3.4"] 24 | rails: ["6.1", "7.0", "7.1", "7.2"] 25 | 26 | env: 27 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails }}.gemfile 28 | 29 | steps: 30 | - uses: actions/checkout@v3 31 | 32 | - name: Setup ruby 33 | uses: ruby/setup-ruby@v1 34 | with: 35 | ruby-version: ${{ matrix.ruby }} 36 | bundler-cache: true 37 | 38 | - uses: actions/setup-node@v3 39 | with: 40 | node-version: 18 41 | cache: yarn 42 | cache-dependency-path: client-app/yarn.lock 43 | 44 | - name: Yarn install 45 | working-directory: client-app 46 | run: yarn install 47 | 48 | - name: Build JS app 49 | run: bash build_client_app.sh 50 | 51 | - name: Tests (no Rails) 52 | run: bundle exec rake test 53 | 54 | - name: Tests (Railtie) 55 | run: bundle exec rake test TEST=test/logster/test_railtie.rb 56 | 57 | frontend: 58 | runs-on: ubuntu-latest 59 | 60 | steps: 61 | - uses: actions/checkout@v3 62 | 63 | - uses: actions/setup-node@v3 64 | with: 65 | node-version: 18 66 | cache: yarn 67 | cache-dependency-path: client-app/yarn.lock 68 | 69 | - name: Yarn install 70 | working-directory: client-app 71 | run: yarn install 72 | 73 | - name: JS tests 74 | working-directory: client-app 75 | run: yarn test:ember 76 | 77 | linting: 78 | runs-on: ubuntu-latest 79 | 80 | steps: 81 | - uses: actions/checkout@v3 82 | 83 | - name: Setup ruby 84 | uses: ruby/setup-ruby@v1 85 | with: 86 | ruby-version: 3.2 87 | bundler-cache: true 88 | 89 | - name: Ruby lint 90 | run: bundle exec rubocop 91 | 92 | - uses: actions/setup-node@v3 93 | if: ${{ !cancelled() }} 94 | with: 95 | node-version: 18 96 | cache: yarn 97 | cache-dependency-path: client-app/yarn.lock 98 | 99 | - name: Yarn install 100 | if: ${{ !cancelled() }} 101 | working-directory: client-app 102 | run: yarn install 103 | 104 | - name: Syntax Tree 105 | if: ${{ !cancelled() }} 106 | run: | 107 | bundle exec stree check Gemfile $(git ls-files '*.rb') $(git ls-files '*.rake') $(git ls-files '*.thor') 108 | 109 | - name: JS linting 110 | if: ${{ !cancelled() }} 111 | working-directory: client-app 112 | run: yarn lint 113 | 114 | publish: 115 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 116 | needs: [backend, frontend, linting] 117 | runs-on: ubuntu-latest 118 | 119 | steps: 120 | - uses: actions/checkout@v3 121 | 122 | - uses: actions/setup-node@v3 123 | with: 124 | node-version: 18 125 | cache: yarn 126 | cache-dependency-path: client-app/yarn.lock 127 | 128 | - name: Yarn install 129 | working-directory: client-app 130 | run: yarn install 131 | 132 | - name: Build JS app 133 | run: bash build_client_app.sh 134 | 135 | - name: Release Gem 136 | uses: discourse/publish-rubygems-action@v2 137 | env: 138 | RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} 139 | GIT_EMAIL: team@discourse.org 140 | GIT_NAME: discoursebot 141 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | assets/javascript/*.js 3 | assets/stylesheets/*.css 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-discourse: default.yml 3 | inherit_mode: 4 | merge: 5 | - Exclude 6 | AllCops: 7 | Exclude: 8 | - 'client-app/**/*' 9 | Discourse/Plugins/NamespaceConstants: 10 | Enabled: false 11 | -------------------------------------------------------------------------------- /.streerc: -------------------------------------------------------------------------------- 1 | --print-width=100 2 | --plugins=plugin/trailing_comma -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in rack-log-viewer.gemspec 6 | gemspec 7 | 8 | group :development, :test do 9 | gem "rails", ">= 6.1" 10 | end 11 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A sample Guardfile 4 | # More info at https://github.com/guard/guard#readme 5 | 6 | guard :minitest do 7 | watch(%r{^test/(.*)\/?test_(.*)\.rb$}) 8 | watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "test/#{m[1]}test_#{m[2]}.rb" } 9 | watch(%r{^test/test_helper\.rb$}) { 'test' } 10 | end 11 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Sam Saffron 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | 6 | Rake::TestTask.new do |t| 7 | t.test_files = FileList["test/**/test_*"].exclude(%r{test/logster/test_railtie\.rb}) 8 | end 9 | 10 | task(default: :test) 11 | 12 | desc "Starts Sinatra and Ember servers" 13 | task :client_dev do 14 | begin 15 | pid = 16 | spawn( 17 | "cd website && LOGSTER_ENV=development BUNDLE_GEMFILE=Gemfile bundle exec rackup --host 0.0.0.0", 18 | ) 19 | pid2 = spawn("cd client-app && npx ember s --proxy http://localhost:9292") 20 | Process.wait pid 21 | Process.wait pid2 22 | rescue Interrupt => e 23 | sleep 0.5 24 | puts "Done!" 25 | exit 0 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /assets/images/Icon-144_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/logster/4209863d0c9ad8f52ec94c11f6d9215cb9885b65/assets/images/Icon-144_rounded.png -------------------------------------------------------------------------------- /assets/images/Icon-144_square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/logster/4209863d0c9ad8f52ec94c11f6d9215cb9885b65/assets/images/Icon-144_square.png -------------------------------------------------------------------------------- /assets/images/icon_144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/logster/4209863d0c9ad8f52ec94c11f6d9215cb9885b65/assets/images/icon_144x144.png -------------------------------------------------------------------------------- /assets/images/icon_64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/logster/4209863d0c9ad8f52ec94c11f6d9215cb9885b65/assets/images/icon_64x64.png -------------------------------------------------------------------------------- /assets/javascript/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/logster/4209863d0c9ad8f52ec94c11f6d9215cb9885b65/assets/javascript/.gitkeep -------------------------------------------------------------------------------- /assets/stylesheets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/logster/4209863d0c9ad8f52ec94c11f6d9215cb9885b65/assets/stylesheets/.gitkeep -------------------------------------------------------------------------------- /bin/guard: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'guard' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require 'pathname' 12 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | require 'rubygems' 16 | require 'bundler/setup' 17 | 18 | load Gem.bin_path('guard', 'guard') 19 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require 'pathname' 12 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | require 'rubygems' 16 | require 'bundler/setup' 17 | 18 | load Gem.bin_path('rake', 'rake') 19 | -------------------------------------------------------------------------------- /build_client_app.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | (cd client-app && yarn && yarn ember build --environment=${1:-production}) 4 | 5 | rm -f assets/javascript/* 6 | rm -f assets/stylesheets/client-app.css 7 | rm -f assets/stylesheets/vendor.css 8 | 9 | cp client-app/dist/assets/*.js assets/javascript/ 10 | cp client-app/dist/assets/*.css assets/stylesheets/ 11 | -------------------------------------------------------------------------------- /client-app/.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.hbs] 16 | insert_final_newline = false 17 | 18 | [*.{diff,md}] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /client-app/.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Ember CLI sends analytics information by default. The data is completely 4 | anonymous, but there are times when you might want to disable this behavior. 5 | 6 | Setting `disableAnalytics` to true will prevent any data from being sent. 7 | */ 8 | "disableAnalytics": true 9 | } 10 | -------------------------------------------------------------------------------- /client-app/.eslintignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | /vendor/ 4 | 5 | # compiled output 6 | /dist/ 7 | /tmp/ 8 | 9 | # dependencies 10 | /node_modules/ 11 | 12 | # misc 13 | /coverage/ 14 | !.* 15 | .*/ 16 | .eslintcache 17 | 18 | # ember-try 19 | /.node_modules.ember-try/ 20 | /package.json.ember-try 21 | -------------------------------------------------------------------------------- /client-app/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-discourse", 3 | "rules": { 4 | // Temporarily disable some newer rules 5 | "ember/no-classic-components": "off", 6 | "ember/no-component-lifecycle-hooks": "off", 7 | "ember/no-get": "error", 8 | "ember/require-tagless-components": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /client-app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist/ 5 | /tmp/ 6 | 7 | # dependencies 8 | /node_modules/ 9 | 10 | # misc 11 | /.env* 12 | /.pnp* 13 | /.sass-cache 14 | /.eslintcache 15 | /connect.lock 16 | /coverage/ 17 | /libpeerconnection.log 18 | /testem.log 19 | /yarn-error.log 20 | 21 | # ember-try 22 | /.node_modules.ember-try/ 23 | /package.json.ember-try 24 | -------------------------------------------------------------------------------- /client-app/.prettierignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | /vendor/ 4 | 5 | # compiled output 6 | /dist/ 7 | /tmp/ 8 | 9 | # dependencies 10 | /bower_components/ 11 | /node_modules/ 12 | 13 | # misc 14 | /coverage/ 15 | !.* 16 | .eslintcache 17 | 18 | # ember-try 19 | /.node_modules.ember-try/ 20 | /bower.json.ember-try 21 | /package.json.ember-try 22 | -------------------------------------------------------------------------------- /client-app/.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /client-app/.template-lintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ["ember-template-lint-plugin-discourse"], 3 | extends: "discourse:recommended", 4 | rules: { 5 | "no-invalid-interactive": "off", 6 | "no-unbound": "off", 7 | "require-input-label": "off", 8 | "require-valid-alt-text": "off", 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /client-app/.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["tmp", "dist"] 3 | } 4 | -------------------------------------------------------------------------------- /client-app/README.md: -------------------------------------------------------------------------------- 1 | # client-app 2 | 3 | This README outlines the details of collaborating on this Ember application. 4 | A short introduction of this app could easily go here. 5 | 6 | ## Prerequisites 7 | 8 | You will need the following things properly installed on your computer. 9 | 10 | * [Git](https://git-scm.com/) 11 | * [Node.js](https://nodejs.org/) (with yarn) 12 | * [Ember CLI](https://ember-cli.com/) 13 | * [Google Chrome](https://google.com/chrome/) 14 | 15 | ## Installation 16 | 17 | * `git clone ` this repository 18 | * `cd client-app` 19 | * `yarn install` 20 | 21 | ## Running / Development 22 | 23 | * `ember serve` 24 | * Visit your app at [http://localhost:4200/logs](http://localhost:4200). 25 | * Visit your tests at [http://localhost:4200/logs/tests](http://localhost:4200/tests). 26 | 27 | ### Code Generators 28 | 29 | Make use of the many generators for code, try `ember help generate` for more details 30 | 31 | ### Running Tests 32 | 33 | * `ember test` 34 | * `ember test --server` 35 | 36 | ### Linting 37 | 38 | * `yarn lint` 39 | * `yarn lint:fix` 40 | 41 | ### Building 42 | 43 | * `ember build` (development) 44 | * `ember build --environment production` (production) 45 | 46 | ### Deploying 47 | 48 | Specify what it takes to deploy your app. 49 | 50 | ## Further Reading / Useful Links 51 | 52 | * [ember.js](https://emberjs.com/) 53 | * [ember-cli](https://ember-cli.com/) 54 | * Development Browser Extensions 55 | * [ember inspector for chrome](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi) 56 | * [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/) 57 | -------------------------------------------------------------------------------- /client-app/app/app.js: -------------------------------------------------------------------------------- 1 | import Application from "@ember/application"; 2 | import Resolver from "ember-resolver"; 3 | import loadInitializers from "ember-load-initializers"; 4 | import config from "client-app/config/environment"; 5 | 6 | export default class App extends Application { 7 | modulePrefix = config.modulePrefix; 8 | podModulePrefix = config.podModulePrefix; 9 | Resolver = Resolver; 10 | } 11 | 12 | loadInitializers(App, config.modulePrefix); 13 | -------------------------------------------------------------------------------- /client-app/app/components/actions-menu.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import { tagName } from "@ember-decorators/component"; 3 | import Component from "@ember/component"; 4 | import { bound } from "client-app/lib/decorators"; 5 | import { action } from "@ember/object"; 6 | 7 | @classic 8 | @tagName("span") 9 | export default class ActionsMenu extends Component { 10 | showMenu = false; 11 | 12 | willDestroyElement() { 13 | super.willDestroyElement(...arguments); 14 | this.removeOutsideClickHandler(); 15 | } 16 | 17 | @bound 18 | outsideClickHandler(event) { 19 | if ( 20 | this.element && 21 | !this.element.contains(event.target) && 22 | this.element !== event.target 23 | ) { 24 | this.set("showMenu", false); 25 | this.updateMenu(); 26 | } 27 | } 28 | 29 | updateMenu() { 30 | if (this.showMenu) { 31 | this.addOutsideClickHandler(); 32 | } else { 33 | this.removeOutsideClickHandler(); 34 | } 35 | } 36 | 37 | addOutsideClickHandler() { 38 | document.addEventListener("click", this.outsideClickHandler); 39 | } 40 | 41 | removeOutsideClickHandler() { 42 | document.removeEventListener("click", this.outsideClickHandler); 43 | } 44 | 45 | @action 46 | expandMenu() { 47 | this.toggleProperty("showMenu"); 48 | this.updateMenu(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /client-app/app/components/back-to-site-link.js: -------------------------------------------------------------------------------- 1 | import Component from "@ember/component"; 2 | import { computed } from "@ember/object"; 3 | 4 | export default class BackToSiteLink extends Component { 5 | @computed("attrs.text", "attrs.path") 6 | get shouldDisplay() { 7 | return this.text && this.path; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /client-app/app/components/back-trace.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import { computed } from "@ember/object"; 3 | import Component from "@ember/component"; 4 | import Preloaded from "client-app/lib/preload"; 5 | 6 | function appendSlash(str) { 7 | if (str && str[str.length - 1] !== "/") { 8 | return str + "/"; 9 | } else { 10 | return str; 11 | } 12 | } 13 | 14 | function assembleURL({ repo, path, filename, lineNumber, commitSha = null }) { 15 | let url = appendSlash(repo); 16 | 17 | if (!/\/tree\//.test(url)) { 18 | url += "blob/"; 19 | url += commitSha ? `${commitSha}/` : "master/"; 20 | } 21 | 22 | url += path + filename; 23 | 24 | if (/^[0-9]+$/.test(lineNumber)) { 25 | url += `#L${lineNumber}`; 26 | } 27 | 28 | return url; 29 | } 30 | 31 | function shortenLine(line) { 32 | const isGem = line.startsWith(Preloaded.get("gems_dir")); 33 | 34 | if (isGem) { 35 | const gemsDir = Preloaded.get("gems_dir"); 36 | return line.substring(gemsDir.length); 37 | } else { 38 | return line; 39 | } 40 | } 41 | 42 | @classic 43 | export default class BackTrace extends Component { 44 | @computed("env.application_version") 45 | get commitSha() { 46 | let sha = null; 47 | 48 | if (Array.isArray(this.env)) { 49 | sha = this.env.map((e) => e.application_version).filter((e) => e)[0]; 50 | } else if (this.env) { 51 | sha = this.env.application_version; 52 | } 53 | 54 | return sha || Preloaded.get("application_version"); 55 | } 56 | 57 | @computed("backtrace.length", "commitSha") 58 | get lines() { 59 | if (!this.backtrace || this.backtrace.length === 0) { 60 | return []; 61 | } 62 | 63 | return this.backtrace.split("\n").map((line) => { 64 | const shortenedLine = shortenLine(line); 65 | return { 66 | line: shortenedLine, 67 | url: this.findGithubURL(line, shortenedLine), 68 | }; 69 | }); 70 | } 71 | 72 | githubURLForGem(line) { 73 | if (!Preloaded.get("backtrace_links_enabled")) { 74 | return null; 75 | } 76 | 77 | const regexResults = line.match(/([^/]+)\/(.+\/)(.+):(\d+):.*/); 78 | const [, gemWithVersion, path, filename, lineNumber] = regexResults || []; 79 | if (!gemWithVersion) { 80 | return null; 81 | } 82 | 83 | const gemsData = Preloaded.get("gems_data"); 84 | const match = gemsData 85 | .filter((g) => gemWithVersion.startsWith(`${g.name}-`)) 86 | .sortBy("name.length") 87 | .reverse()[0]; 88 | 89 | if (!match) { 90 | return null; 91 | } 92 | 93 | return assembleURL({ repo: match.url, path, filename, lineNumber }); 94 | } 95 | 96 | githubURLForApp(line) { 97 | if (!Preloaded.get("backtrace_links_enabled")) { 98 | return null; 99 | } 100 | 101 | const projectDirs = Preloaded.get("directories"); 102 | const match = projectDirs 103 | .filter((dir) => line.startsWith(dir.path)) 104 | .sortBy("path.length") 105 | .reverse()[0]; 106 | 107 | if (!match) { 108 | return null; 109 | } 110 | 111 | const root = appendSlash(match.path); 112 | const lineWithoutRoot = line.substring(root.length); 113 | const hasSlash = lineWithoutRoot.includes("/"); 114 | let path = ""; 115 | let filename; 116 | let lineNumber; 117 | let remaining; 118 | 119 | if (hasSlash) { 120 | [, path, filename, lineNumber, remaining] = 121 | lineWithoutRoot.match(/(.+\/)(.+):(\d+)(:.*)/) || []; 122 | } else { 123 | [, filename, lineNumber, remaining] = 124 | lineWithoutRoot.match(/(.+):(\d+)(:.*)/) || []; 125 | } 126 | 127 | if (!filename || !lineNumber || !remaining) { 128 | return null; 129 | } 130 | 131 | const commitSha = match.main_app ? this.commitSha : null; 132 | 133 | return assembleURL({ 134 | repo: match.url, 135 | path, 136 | filename, 137 | lineNumber, 138 | commitSha, 139 | }); 140 | } 141 | 142 | findGithubURL(line, shortenedLine) { 143 | const projectDirs = Preloaded.get("directories") || []; 144 | const isGem = line.startsWith(Preloaded.get("gems_dir")); 145 | const isApp = projectDirs.some((p) => line.startsWith(p.path)); 146 | 147 | if (isGem || !isApp) { 148 | return this.githubURLForGem(shortenedLine); 149 | } else { 150 | return this.githubURLForApp(line); 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /client-app/app/components/env-tab.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import { computed } from "@ember/object"; 3 | import Component from "@ember/component"; 4 | import { buildHashString, clone } from "client-app/lib/utilities"; 5 | import Preload from "client-app/lib/preload"; 6 | import { htmlSafe } from "@ember/template"; 7 | 8 | @classic 9 | export default class EnvTab extends Component { 10 | @computed("currentEnvPosition", "isEnvArray", "message.env") 11 | get currentEnv() { 12 | if (this.isEnvArray) { 13 | return this.message.env[this.currentEnvPosition]; 14 | } else { 15 | return this.message.env; 16 | } 17 | } 18 | 19 | @computed("message.env") 20 | get isEnvArray() { 21 | return Array.isArray(this.message.env); 22 | } 23 | 24 | @computed("currentEnv", "expanded.[]", "isEnvArray", "message.env") 25 | get html() { 26 | if (!this.isEnvArray) { 27 | return htmlSafe(buildHashString(this.message.env)); 28 | } 29 | 30 | const currentEnv = clone(this.currentEnv); 31 | const expandableKeys = Preload.get("env_expandable_keys") || []; 32 | 33 | for (const key of expandableKeys) { 34 | if ( 35 | Object.prototype.hasOwnProperty.call(currentEnv, key) && 36 | !Array.isArray(currentEnv[key]) 37 | ) { 38 | const list = [currentEnv[key]]; 39 | for (const env of this.message.env) { 40 | if (env[key] && !list.includes(env[key])) { 41 | list.push(env[key]); 42 | } 43 | } 44 | currentEnv[key] = list.length > 1 ? list : list[0]; 45 | } 46 | } 47 | 48 | return htmlSafe(buildHashString(currentEnv, false, this.expanded || [])); 49 | } 50 | 51 | click(e) { 52 | const elem = e.target; 53 | const dataKey = elem.dataset.key; 54 | const expandableKeys = Preload.get("env_expandable_keys") || []; 55 | 56 | if ( 57 | expandableKeys.includes(dataKey) && 58 | elem.classList.contains("expand-list") 59 | ) { 60 | e.preventDefault(); 61 | if (this.expanded) { 62 | this.expanded.pushObject(dataKey); 63 | } else { 64 | this.set("expanded", [dataKey]); 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /client-app/app/components/message-info.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import { bool } from "@ember/object/computed"; 3 | import Component from "@ember/component"; 4 | import { action, computed } from "@ember/object"; 5 | import Preload from "client-app/lib/preload"; 6 | 7 | @classic 8 | export default class MessageInfo extends Component { 9 | @bool("currentRow.group") showSolveAllButton; 10 | 11 | @computed("currentMessage.protected", "showSolveAllButton", "showSolveButton") 12 | get buttons() { 13 | const protect = this.currentMessage.protected; 14 | const buttons = []; 15 | 16 | if (!protect && this.showSolveButton) { 17 | buttons.push({ 18 | klass: "solve", 19 | action: this.solve, 20 | icon: "check-square", 21 | label: "Solve", 22 | prefix: "far", 23 | danger: true, 24 | }); 25 | } 26 | 27 | if (this.showSolveAllButton) { 28 | buttons.push({ 29 | klass: "solve-all", 30 | action: this.solveAll, 31 | icon: "check-square", 32 | label: "Solve All", 33 | prefix: "far", 34 | danger: true, 35 | }); 36 | } 37 | 38 | if (protect) { 39 | buttons.push({ 40 | klass: "unprotect", 41 | action: this.unprotect, 42 | icon: "unlock", 43 | prefix: "fas", 44 | label: "Unprotect", 45 | }); 46 | } else { 47 | buttons.push( 48 | { 49 | klass: "remove", 50 | action: this.remove, 51 | icon: "trash-alt", 52 | label: "Remove", 53 | prefix: "far", 54 | danger: true, 55 | }, 56 | { 57 | klass: "protect", 58 | action: this.protect, 59 | icon: "lock", 60 | prefix: "fas", 61 | label: "Protect", 62 | } 63 | ); 64 | } 65 | 66 | buttons.push({ 67 | klass: "copy", 68 | action: this.copy, 69 | icon: "copy", 70 | prefix: "far", 71 | label: "Copy", 72 | }); 73 | 74 | return buttons; 75 | } 76 | 77 | @computed("showSolveAllButton", "currentMessage.{canSolve,env}") 78 | get showSolveButton() { 79 | if (this.showSolveAllButton) { 80 | return false; 81 | } 82 | // env isn't loaded until you switch to the env tab 83 | // so if we don't have env we show the button if 84 | // application_version is provided in the config 85 | return this.currentMessage.env 86 | ? this.currentMessage.canSolve 87 | : !!Preload.get("application_version"); 88 | } 89 | 90 | @action 91 | copy() { 92 | const header = this.currentMessage.showCount 93 | ? `Message (${this.currentMessage.count} copies reported)` 94 | : "Message"; 95 | const message = `${header}\n\n${this.currentMessage.message}`; 96 | 97 | const backtrace = `Backtrace\n\n${this.currentMessage.backtrace}`; 98 | 99 | const httpHosts = Array.isArray(this.currentMessage.env) 100 | ? this.currentMessage.env 101 | .map((e) => e["HTTP_HOST"]) 102 | .filter((e, i, a) => e && a.indexOf(e) === i) 103 | .join(", ") 104 | : this.currentMessage.env["HTTP_HOST"]; 105 | 106 | const env = httpHosts ? `Env\n\nHTTP HOSTS: ${httpHosts}` : ""; 107 | const lines = [message, backtrace, env].filter((l) => l).join("\n\n"); 108 | 109 | const temp = document.createElement("TEXTAREA"); 110 | document.body.appendChild(temp); 111 | temp.value = lines; 112 | temp.select(); 113 | document.execCommand("copy"); 114 | document.body.removeChild(temp); 115 | } 116 | 117 | @action 118 | tabChanged(newTab) { 119 | this.onTabChange?.(newTab); 120 | } 121 | 122 | @action 123 | protect() { 124 | this.currentMessage.protect(); 125 | } 126 | 127 | @action 128 | unprotect() { 129 | this.currentMessage.unprotect(); 130 | } 131 | 132 | @action 133 | remove() { 134 | this.removeMessage(this.currentMessage); 135 | } 136 | 137 | @action 138 | solve() { 139 | this.solveMessage(this.currentMessage); 140 | } 141 | 142 | @action 143 | solveAll() { 144 | this.currentRow.solveAll(); 145 | } 146 | 147 | @action 148 | share() { 149 | window.location.pathname = this.currentMessage.shareUrl; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /client-app/app/components/message-row.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import { classNameBindings, tagName } from "@ember-decorators/component"; 3 | import Component from "@ember/component"; 4 | 5 | let CHECKED_BOTTOM; 6 | let STICK_TO_BOTTOM; 7 | 8 | @classic 9 | @tagName("div") 10 | @classNameBindings("model.rowClass", ":message-row", "model.selected:selected") 11 | export default class MessageRow extends Component { 12 | willInsertElement() { 13 | super.willInsertElement(...arguments); 14 | if (CHECKED_BOTTOM) { 15 | return; 16 | } 17 | 18 | const topPanel = document.getElementById("top-panel"); 19 | if (!topPanel) { 20 | return; 21 | } 22 | 23 | const height = parseFloat(getComputedStyle(topPanel).height); 24 | STICK_TO_BOTTOM = topPanel.scrollHeight - 20 < height + topPanel.scrollTop; 25 | CHECKED_BOTTOM = true; 26 | } 27 | 28 | didInsertElement() { 29 | super.didInsertElement(...arguments); 30 | const topPanel = document.getElementById("top-panel"); 31 | if (!topPanel) { 32 | return; 33 | } 34 | 35 | CHECKED_BOTTOM = false; 36 | if (STICK_TO_BOTTOM) { 37 | STICK_TO_BOTTOM = false; 38 | topPanel.scrollTop = 39 | topPanel.scrollHeight - parseFloat(getComputedStyle(topPanel).height); 40 | } 41 | } 42 | 43 | click() { 44 | this.selectRow(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client-app/app/components/page-nav.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import { classNameBindings, classNames } from "@ember-decorators/component"; 3 | import { equal } from "@ember/object/computed"; 4 | import Component from "@ember/component"; 5 | import { action, computed } from "@ember/object"; 6 | 7 | @classic 8 | @classNames("nav-controls") 9 | @classNameBindings("extraClasses") 10 | export default class PageNav extends Component { 11 | @equal("position", 0) disableBackButtons; 12 | 13 | @computed("position", "list.length") 14 | get disableForwardButtons() { 15 | return this.position === this.list.length - 1; 16 | } 17 | 18 | @computed("position") 19 | get displayNumber() { 20 | return this.position + 1; 21 | } 22 | 23 | @action 24 | takeStep(dir) { 25 | const amount = dir === "back" ? -1 : 1; 26 | if (amount === 1 && this.disableForwardButtons) { 27 | return; 28 | } 29 | if (amount === -1 && this.disableBackButtons) { 30 | return; 31 | } 32 | 33 | const newPos = this.position + amount; 34 | this.navigate(newPos); 35 | } 36 | 37 | @action 38 | bigJump(dir) { 39 | const newPos = dir === "back" ? 0 : this.list.length - 1; 40 | this.navigate(newPos); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client-app/app/components/panel-resizer.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import { classNames } from "@ember-decorators/component"; 3 | import { inject as service } from "@ember/service"; 4 | import Component from "@ember/component"; 5 | import { scheduleOnce, throttle } from "@ember/runloop"; 6 | import { bound } from "client-app/lib/decorators"; 7 | 8 | const MOVE_EVENTS = ["touchmove", "mousemove"]; 9 | const UP_EVENTS = ["touchend", "mouseup"]; 10 | const DOWN_EVENTS = ["touchstart", "mousedown"]; 11 | 12 | @classic 13 | @classNames("divider") 14 | export default class PanelResizer extends Component { 15 | @service events; 16 | 17 | resizing = false; 18 | 19 | didInsertElement() { 20 | super.didInsertElement(...arguments); 21 | // inspired by http://plugins.jquery.com/misc/textarea.js 22 | this.set("divider", document.querySelector(".divider")); 23 | for (const name of DOWN_EVENTS) { 24 | this.divider.addEventListener(name, this.dividerClickHandler); 25 | } 26 | scheduleOnce("afterRender", this, "initialDivideView"); 27 | } 28 | 29 | willDestroyElement() { 30 | super.willDestroyElement(...arguments); 31 | for (const name of DOWN_EVENTS) { 32 | this.divider.removeEventListener(name, this.dividerClickHandler); 33 | } 34 | } 35 | 36 | initialDivideView() { 37 | const amount = (localStorage && localStorage.logster_divider_bottom) || 300; 38 | const fromTop = window.innerHeight - parseInt(amount, 10); 39 | this.divideView(fromTop); 40 | } 41 | 42 | divideView(fromTop) { 43 | const height = window.innerHeight; 44 | const fromBottom = height - fromTop; 45 | 46 | if (fromTop < 100 || fromTop + 170 > height) { 47 | return; 48 | } 49 | 50 | this.divider.style.bottom = `${fromBottom - 5}px`; 51 | this.events.trigger("panelResized", fromBottom); 52 | } 53 | 54 | @bound 55 | performDrag(e) { 56 | throttle(this, this.throttledPerformDrag, e, 25); 57 | } 58 | 59 | throttledPerformDrag(e) { 60 | if (this.resizing) { 61 | this.divideView( 62 | e.clientY || (e.touches && e.touches[0] && e.touches[0].clientY) 63 | ); 64 | } 65 | } 66 | 67 | @bound 68 | endDrag /* e */() { 69 | const overlay = document.getElementById("overlay"); 70 | if (overlay) { 71 | overlay.parentElement.removeChild(overlay); 72 | } 73 | 74 | this.set("resizing", false); 75 | 76 | if (localStorage) { 77 | localStorage.logster_divider_bottom = parseInt( 78 | this.divider.style.bottom, 79 | 10 80 | ); 81 | } 82 | 83 | for (const name of MOVE_EVENTS) { 84 | document.removeEventListener(name, this.performDrag); 85 | } 86 | for (const name of UP_EVENTS) { 87 | document.removeEventListener(name, this.endDrag); 88 | } 89 | } 90 | 91 | @bound 92 | dividerClickHandler(e) { 93 | e.preventDefault(); // for disabling pull-down-to-refresh on mobile 94 | 95 | const overlay = document.createElement("DIV"); 96 | overlay.id = "overlay"; 97 | document.body.appendChild(overlay); 98 | 99 | this.set("resizing", true); 100 | 101 | for (const name of MOVE_EVENTS) { 102 | document.addEventListener(name, this.performDrag); 103 | } 104 | for (const name of UP_EVENTS) { 105 | document.addEventListener(name, this.endDrag); 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /client-app/app/components/patterns-list.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import { equal, not } from "@ember/object/computed"; 3 | import Component from "@ember/component"; 4 | import { action, computed } from "@ember/object"; 5 | import Pattern from "client-app/models/pattern-item"; 6 | import { ajax } from "client-app/lib/utilities"; 7 | 8 | @classic 9 | export default class PatternsList extends Component { 10 | @not("mutable") immutable; 11 | @equal("key", "suppression") showCounter; 12 | 13 | init() { 14 | super.init(...arguments); 15 | 16 | if (this.patterns.length < 1 && this.mutable) { 17 | this.create(); 18 | } 19 | } 20 | 21 | @computed("patterns.[]", "newPatterns.[]") 22 | get allPatterns() { 23 | const patterns = this.patterns; 24 | const newPatterns = this.newPatterns || []; 25 | return [...newPatterns.reverse(), ...patterns.reverse()]; 26 | } 27 | 28 | makeAPICall(data = {}) { 29 | const { method } = data; 30 | delete data.method; 31 | 32 | return ajax(`/patterns/${this.key}.json`, { method, data }); 33 | } 34 | 35 | finallyBlock(pattern) { 36 | pattern.set("saving", false); 37 | } 38 | 39 | catchBlock(pattern, response) { 40 | if (response.responseText) { 41 | pattern.set("error", response.responseText); 42 | } else { 43 | pattern.set("error", "Unknown error occurred. Please see dev console."); 44 | } 45 | } 46 | 47 | requestInit(pattern) { 48 | pattern.setProperties({ 49 | saving: true, 50 | error: null, 51 | }); 52 | } 53 | 54 | @action 55 | create() { 56 | if (!this.newPatterns) { 57 | this.set("newPatterns", []); 58 | } 59 | this.newPatterns.pushObject(Pattern.create({ isNew: true })); 60 | } 61 | 62 | @action 63 | async trash(pattern) { 64 | if (pattern.isNew) { 65 | this.newPatterns.removeObject(pattern); 66 | pattern.destroy(); 67 | return; 68 | } 69 | 70 | this.requestInit(pattern); 71 | 72 | try { 73 | await this.makeAPICall({ 74 | method: "DELETE", 75 | pattern: pattern.value, 76 | }); 77 | 78 | this.patterns.removeObject(pattern); 79 | pattern.destroy(); 80 | } catch (response) { 81 | this.catchBlock(pattern, response); 82 | } finally { 83 | this.finallyBlock(pattern); 84 | } 85 | } 86 | 87 | @action 88 | async save(pattern) { 89 | this.requestInit(pattern); 90 | 91 | try { 92 | if (pattern.isNew) { 93 | const response = await this.makeAPICall({ 94 | method: "POST", 95 | pattern: pattern.valueBuffer, 96 | retroactive: !!pattern.retroactive, 97 | }); 98 | 99 | pattern.updateValue(response.pattern); 100 | pattern.set("isNew", false); 101 | this.patterns.pushObject(pattern); 102 | this.newPatterns.removeObject(pattern); 103 | } else { 104 | const response = await this.makeAPICall({ 105 | method: "PUT", 106 | pattern: pattern.value, 107 | new_pattern: pattern.valueBuffer, 108 | }); 109 | 110 | pattern.updateValue(response.pattern); 111 | pattern.set("count", 0); 112 | } 113 | } catch (response) { 114 | this.catchBlock(pattern, response); 115 | } finally { 116 | this.finallyBlock(pattern); 117 | } 118 | } 119 | 120 | @action 121 | async resetCount(pattern) { 122 | pattern.set("saving", true); 123 | 124 | try { 125 | await ajax("/reset-count.json", { 126 | method: "PUT", 127 | data: { pattern: pattern.value, hard: !!pattern.hard }, 128 | }); 129 | 130 | pattern.set("count", 0); 131 | } catch (response) { 132 | this.catchBlock(pattern, response); 133 | } finally { 134 | this.finallyBlock(pattern); 135 | } 136 | } 137 | 138 | @action 139 | checkboxChanged(pattern) { 140 | pattern.toggleProperty("retroactive"); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /client-app/app/components/tab-contents.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import { classNameBindings } from "@ember-decorators/component"; 3 | import Component from "@ember/component"; 4 | 5 | @classic 6 | @classNameBindings("active", ":content", "name") 7 | export default class TabContents extends Component { 8 | isLink = false; 9 | 10 | didInsertElement() { 11 | super.didInsertElement(...arguments); 12 | this.tabActions.addTab(this); 13 | 14 | if (this.defaultTab) { 15 | this.tabActions.selectTab(this); 16 | } 17 | } 18 | 19 | willDestroyElement() { 20 | super.willDestroyElement(...arguments); 21 | this.tabActions.removeTab(this); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client-app/app/components/tabbed-section.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import Component from "@ember/component"; 3 | import { A } from "@ember/array"; 4 | import { action } from "@ember/object"; 5 | 6 | @classic 7 | export default class TabbedSection extends Component { 8 | tabs = A(); 9 | selected = null; 10 | 11 | @action 12 | selectTab(tab) { 13 | if (tab.isLink) { 14 | this.triggerAction(tab.action); 15 | return; 16 | } 17 | 18 | if (this.selected) { 19 | this.selected.set("active", false); 20 | } 21 | 22 | this.set("selected", tab); 23 | tab.set("active", true); 24 | 25 | this.onTabChange(tab.name); 26 | } 27 | 28 | @action 29 | addTab(tab) { 30 | this.tabs.addObject(tab); 31 | 32 | if (!this.selected && !tab.isLink) { 33 | this.selectTab(tab); 34 | } 35 | } 36 | 37 | @action 38 | removeTab(tab) { 39 | if (this.selected === tab) { 40 | this.set("selected", null); 41 | } 42 | 43 | this.tabs.removeObject(tab); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client-app/app/components/time-formatter.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import { tagName } from "@ember-decorators/component"; 3 | import { computed } from "@ember/object"; 4 | import Component from "@ember/component"; 5 | import { formatTime } from "client-app/lib/utilities"; 6 | import { later } from "@ember/runloop"; 7 | 8 | const UPDATE_INTERVAL = 60_000; 9 | 10 | @classic 11 | @tagName("") 12 | export default class TimeFormatter extends Component { 13 | didInsertElement() { 14 | super.didInsertElement(...arguments); 15 | later(this, this.updateTime, UPDATE_INTERVAL); 16 | } 17 | 18 | @computed("timestamp") 19 | get title() { 20 | return moment(this.timestamp).format(); 21 | } 22 | 23 | @computed("timestamp") 24 | get time() { 25 | return formatTime(this.timestamp); 26 | } 27 | 28 | updateTime() { 29 | if (this.isDestroying || this.isDestroyed) { 30 | return; 31 | } 32 | 33 | this.notifyPropertyChange("timestamp"); 34 | later(this, this.updateTime, UPDATE_INTERVAL); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /client-app/app/controllers/index.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import { debounce } from "@ember/runloop"; 3 | import { action, computed } from "@ember/object"; 4 | import Controller from "@ember/controller"; 5 | import { 6 | ajax, 7 | getLocalStorage, 8 | setLocalStorage, 9 | } from "client-app/lib/utilities"; 10 | import Preload from "client-app/lib/preload"; 11 | import { tracked } from "@glimmer/tracking"; 12 | 13 | @classic 14 | export default class IndexController extends Controller { 15 | @tracked loading = false; 16 | @tracked buildingGroupingPattern = false; 17 | @tracked rowMessagesForGroupingPattern = []; 18 | 19 | showDebug = getLocalStorage("showDebug", false); 20 | showInfo = getLocalStorage("showInfo", false); 21 | showWarn = getLocalStorage("showWarn", true); 22 | showErr = getLocalStorage("showErr", true); 23 | showFatal = getLocalStorage("showFatal", true); 24 | search = null; 25 | queryParams = ["search"]; 26 | 27 | @computed 28 | get showSettings() { 29 | return Preload.get("patterns_enabled"); 30 | } 31 | 32 | @computed 33 | get backToSiteLinkText() { 34 | return Preload.get("back_to_site_link_text"); 35 | } 36 | 37 | @computed 38 | get backToSiteLinkPath() { 39 | return Preload.get("back_to_site_link_path"); 40 | } 41 | 42 | @computed 43 | get hasTopMenu() { 44 | return this.backToSiteLinkText && this.backToSiteLinkPath; 45 | } 46 | 47 | get actionsInMenu() { 48 | return ( 49 | /mobile/i.test(navigator.userAgent) && !/iPad/.test(navigator.userAgent) 50 | ); 51 | } 52 | 53 | get showCreateGroupingPattern() { 54 | return ( 55 | this.buildingGroupingPattern && 56 | this.rowMessagesForGroupingPattern.length > 1 57 | ); 58 | } 59 | 60 | @computed("search") 61 | get searchTerm() { 62 | if (this.search) { 63 | this.doSearch(this.search); 64 | return this.search; 65 | } 66 | return null; 67 | } 68 | 69 | async doSearch(term) { 70 | this.model.set("search", term); 71 | this.loading = true; 72 | await this.model.reload(); 73 | this.loading = false; 74 | this.model.updateSelectedRow(); 75 | } 76 | 77 | resizePanels(amount) { 78 | const bottomPanel = document.getElementById("bottom-panel"); 79 | const topPanel = document.getElementById("top-panel"); 80 | bottomPanel.style.height = `${amount - 13}px`; 81 | topPanel.style.bottom = `${amount + 12}px`; 82 | } 83 | 84 | @action 85 | expandMessage(message) { 86 | message.expand(); 87 | } 88 | 89 | @action 90 | selectRowAction(row, opts = {}) { 91 | this.model.selectRow(row, opts); 92 | } 93 | 94 | @action 95 | handleCheckboxChange(row, event) { 96 | if (event.target.checked) { 97 | this.rowMessagesForGroupingPattern = [ 98 | ...this.rowMessagesForGroupingPattern, 99 | row.message, 100 | ]; 101 | } else { 102 | this.rowMessagesForGroupingPattern = 103 | this.rowMessagesForGroupingPattern.filter((i) => i !== row.message); 104 | } 105 | } 106 | 107 | @action 108 | tabChangedAction(newTab) { 109 | this.model.tabChanged(newTab); 110 | } 111 | 112 | @action 113 | showMoreBefore() { 114 | this.model.showMoreBefore(); 115 | } 116 | 117 | @action 118 | loadMore() { 119 | return this.model.loadMore(); 120 | } 121 | 122 | @action 123 | async clear() { 124 | // eslint-disable-next-line no-alert 125 | if (confirm("Clear the logs?\n\nCancel = No, OK = Clear")) { 126 | await ajax("/clear", { type: "POST" }); 127 | this.model.reload(); 128 | this.loading = false; 129 | } 130 | } 131 | 132 | @action 133 | removeMessage(msg) { 134 | const group = this.model.currentRow.group ? this.model.currentRow : null; 135 | const rows = this.model.rows; 136 | const idx = group ? rows.indexOf(group) : rows.indexOf(msg); 137 | 138 | msg.destroy(); 139 | msg.set("selected", false); 140 | this.model.set("total", this.model.total - 1); 141 | let removedRow = false; 142 | let messageIndex = 0; 143 | 144 | if (group) { 145 | messageIndex = group.messages.indexOf(msg); 146 | group.messages.removeObject(msg); 147 | messageIndex = Math.min(messageIndex, group.messages.length - 1); 148 | if (group.messages.length === 0) { 149 | rows.removeObject(group); 150 | removedRow = true; 151 | } 152 | } else { 153 | rows.removeObject(msg); 154 | removedRow = true; 155 | } 156 | 157 | if (removedRow) { 158 | if (idx > 0) { 159 | this.model.selectRow(rows[idx - 1]); 160 | } else if (this.model.total > 0) { 161 | this.model.selectRow(rows[0]); 162 | } else { 163 | this.model.reload(); 164 | } 165 | } else if (group) { 166 | this.model.selectRow(rows[idx], { messageIndex }); 167 | } 168 | } 169 | 170 | @action 171 | solveMessage(msg) { 172 | this.model.solve(msg); 173 | } 174 | 175 | @action 176 | groupedMessageChangedAction(newPosition) { 177 | this.model.groupedMessageChanged(newPosition); 178 | } 179 | 180 | @action 181 | envChangedAction(newPosition) { 182 | this.model.envChanged(newPosition); 183 | } 184 | 185 | @action 186 | async updateFilter(name) { 187 | this.toggleProperty(name); 188 | this.model.set(name, this[name]); 189 | setLocalStorage(name, this[name]); 190 | this.loading = true; 191 | await this.model.reload(); 192 | this.loading = false; 193 | this.model.updateSelectedRow(); 194 | } 195 | 196 | @action 197 | updateSearch(event) { 198 | const term = event.target.value; 199 | 200 | if (term === this.search) { 201 | return; 202 | } 203 | 204 | if (term && term.length === 1) { 205 | return; 206 | } 207 | 208 | debounce(this, this.doSearch, term, 250); 209 | } 210 | 211 | @action 212 | toggleGroupingPatternFromSelectedRows() { 213 | this.buildingGroupingPattern = !this.buildingGroupingPattern; 214 | this.rowMessagesForGroupingPattern = []; 215 | } 216 | 217 | @action 218 | async createGroupingPatternFromSelectedRows() { 219 | let match = this.findLongestMatchingPrefix( 220 | this.rowMessagesForGroupingPattern 221 | ); 222 | match = this.escapeRegExp(match); 223 | 224 | if ( 225 | match.trim().length && 226 | // eslint-disable-next-line no-alert 227 | confirm( 228 | `Do you want to create the grouping pattern\n\n"${match}"\n\nCancel = No, OK = Create` 229 | ) 230 | ) { 231 | await ajax("/patterns/grouping.json", { 232 | method: "POST", 233 | data: { 234 | pattern: match, 235 | }, 236 | }); 237 | this.rowMessagesForGroupingPattern = []; 238 | this.buildingGroupingPattern = false; 239 | this.model.reload(); 240 | } else if (!match.trim().length) { 241 | // eslint-disable-next-line no-alert 242 | alert("Can not create a grouping pattern with the given rows"); 243 | } 244 | } 245 | 246 | findLongestMatchingPrefix(strings) { 247 | const shortestString = strings.reduce( 248 | (shortest, str) => (str.length < shortest.length ? str : shortest), 249 | strings[0] 250 | ); 251 | 252 | let longestMatchingSubstring = ""; 253 | for (let i = 0; i < shortestString.length; i++) { 254 | const currentSubstring = shortestString.substring(0, i + 1); 255 | 256 | if (strings.every((str) => str.includes(currentSubstring))) { 257 | longestMatchingSubstring = currentSubstring; 258 | } else { 259 | break; 260 | } 261 | } 262 | 263 | return longestMatchingSubstring; 264 | } 265 | 266 | escapeRegExp(string) { 267 | return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /client-app/app/controllers/show.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import Controller, { inject as controller } from "@ember/controller"; 3 | import { action } from "@ember/object"; 4 | import { inject as service } from "@ember/service"; 5 | 6 | @classic 7 | export default class ShowController extends Controller { 8 | @service router; 9 | @controller("index") indexController; 10 | 11 | envPosition = 0; 12 | 13 | @action 14 | protect() { 15 | this.model.protect(); 16 | } 17 | 18 | @action 19 | unprotect() { 20 | this.model.unprotect(); 21 | } 22 | 23 | @action 24 | async solveMessage(msg) { 25 | await msg.solve(); 26 | this.router.transitionTo("index"); 27 | } 28 | 29 | @action 30 | async removeMessage(msg) { 31 | await msg.destroy(); 32 | this.router.transitionTo("index"); 33 | } 34 | 35 | @action 36 | envChanged(newPosition) { 37 | this.set("envPosition", newPosition); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /client-app/app/helpers/logster-url.js: -------------------------------------------------------------------------------- 1 | import { helper } from "@ember/component/helper"; 2 | import { getRootPath } from "client-app/lib/preload"; 3 | 4 | export function logsterUrl(arr) { 5 | let url = arr[0]; 6 | if (url[0] !== "/") { 7 | url = `/${url}`; 8 | } 9 | return getRootPath() + url; 10 | } 11 | 12 | export default helper(logsterUrl); 13 | -------------------------------------------------------------------------------- /client-app/app/helpers/or.js: -------------------------------------------------------------------------------- 1 | import { helper } from "@ember/component/helper"; 2 | 3 | export function or(params) { 4 | return params.some((p) => p); 5 | } 6 | 7 | export default helper(or); 8 | -------------------------------------------------------------------------------- /client-app/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ClientApp 7 | 8 | 9 | 10 | 11 | 12 | {{content-for "head"}} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {{content-for "head-footer"}} 22 | 23 | 24 | {{content-for "body"}} 25 | 26 | 27 | 28 | 29 | {{content-for "body-footer"}} 30 | 31 | 32 | -------------------------------------------------------------------------------- /client-app/app/initializers/app-init.js: -------------------------------------------------------------------------------- 1 | import { 2 | ajax, 3 | resetTitleCount, 4 | updateHiddenProperty, 5 | } from "client-app/lib/utilities"; 6 | import { setRootPath } from "client-app/lib/preload"; 7 | 8 | export async function initialize(app) { 9 | const config = app.resolveRegistration("config:environment"); 10 | setRootPath(config.rootURL.replace(/\/$/, "")); 11 | 12 | if (config.environment === "development") { 13 | app.deferReadiness(); 14 | 15 | try { 16 | const data = await ajax("/development-preload.json"); 17 | const elem = document.getElementById("preloaded-data"); 18 | elem.setAttribute("data-preloaded", JSON.stringify(data)); 19 | } catch (xhr) { 20 | console.error("Fetching preload data failed.", xhr); // eslint-disable-line no-console 21 | } finally { 22 | app.advanceReadiness(); 23 | } 24 | } 25 | 26 | // config for moment.js 27 | moment.updateLocale("en", { 28 | relativeTime: { 29 | future: "in %s", 30 | past: "%s ago", 31 | s: "secs", 32 | m: "a min", 33 | mm: "%d mins", 34 | h: "an hr", 35 | hh: "%d hrs", 36 | d: "a day", 37 | dd: "%d days", 38 | M: "a mth", 39 | MM: "%d mths", 40 | y: "a yr", 41 | yy: "%d yrs", 42 | }, 43 | }); 44 | 45 | // setup event for updating document title and title count 46 | let hiddenProperty; 47 | let visibilitychange; 48 | 49 | for (const prefix of ["", "webkit", "ms", "moz", "ms"]) { 50 | const check = prefix + (prefix === "" ? "hidden" : "Hidden"); 51 | 52 | if (document[check] !== undefined) { 53 | hiddenProperty = check; 54 | visibilitychange = prefix + "visibilitychange"; 55 | break; 56 | } 57 | } 58 | 59 | updateHiddenProperty(hiddenProperty); 60 | document.addEventListener(visibilitychange, resetTitleCount, false); 61 | 62 | const isMobile = 63 | /mobile/i.test(navigator.userAgent) && !/iPad/.test(navigator.userAgent); 64 | if (isMobile) { 65 | document.body.classList.add("mobile"); 66 | } 67 | } 68 | 69 | export default { 70 | initialize, 71 | }; 72 | -------------------------------------------------------------------------------- /client-app/app/lib/decorators.js: -------------------------------------------------------------------------------- 1 | export function bound(target, key, desc) { 2 | const orig = desc.value; 3 | const boundKey = `_${key}Bound`; 4 | return { 5 | get() { 6 | if (this[boundKey]) { 7 | return this[boundKey]; 8 | } 9 | this.set(boundKey, orig.bind(this)); 10 | return this[boundKey]; 11 | }, 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /client-app/app/lib/preload.js: -------------------------------------------------------------------------------- 1 | import { set } from "@ember/object"; 2 | 3 | let CONTAINER = {}; 4 | let isInitialized = false; 5 | let rootPath; 6 | 7 | export function setRootPath(path) { 8 | rootPath = path; 9 | } 10 | 11 | export function getRootPath() { 12 | return rootPath; 13 | } 14 | 15 | // exported so that it can be used in tests 16 | export function init() { 17 | const dataset = document.getElementById("preloaded-data").dataset; 18 | CONTAINER = dataset.preloaded ? JSON.parse(dataset.preloaded) : {}; 19 | CONTAINER.rootPath = rootPath; 20 | isInitialized = true; 21 | } 22 | 23 | export default { 24 | get(key) { 25 | if (!isInitialized) { 26 | init(); 27 | } 28 | return CONTAINER[key]; 29 | }, 30 | }; 31 | 32 | // used in tests 33 | export function mutatePreload(key, value) { 34 | if (!isInitialized) { 35 | init(); 36 | } 37 | 38 | set(CONTAINER, key, value); 39 | } 40 | 41 | export function uninitialize() { 42 | isInitialized = false; 43 | } 44 | -------------------------------------------------------------------------------- /client-app/app/lib/utilities.js: -------------------------------------------------------------------------------- 1 | import Preload, { getRootPath } from "client-app/lib/preload"; 2 | 3 | const entityMap = { 4 | "&": "&", 5 | "<": "<", 6 | ">": ">", 7 | '"': """, 8 | "'": "'", 9 | "/": "/", 10 | }; 11 | 12 | export function escapeHtml(string) { 13 | return String(string).replace(/[&<>"'/]/g, (s) => entityMap[s]); 14 | } 15 | 16 | export function ajax(url, settings) { 17 | // eslint-disable-next-line no-restricted-globals 18 | return new Promise((resolve, reject) => { 19 | settings ||= {}; 20 | const xhr = new XMLHttpRequest(); 21 | url = getRootPath() + url; 22 | 23 | if (settings.data) { 24 | for (const [param, value] of Object.entries(settings.data)) { 25 | url += url.includes("?") ? "&" : "?"; 26 | url += `${param}=${encodeURIComponent(value)}`; 27 | } 28 | } 29 | 30 | xhr.open(settings.method || settings.type || "GET", url); 31 | xhr.setRequestHeader("X-SILENCE-LOGGER", true); 32 | 33 | if (settings.headers) { 34 | for (const [header, value] of Object.entries(settings.headers)) { 35 | xhr.setRequestHeader(header, value); 36 | } 37 | } 38 | 39 | xhr.onreadystatechange = () => { 40 | if (xhr.readyState === 4) { 41 | let status = xhr.status; 42 | if ((status >= 200 && status < 300) || status === 304) { 43 | const type = xhr.getResponseHeader("Content-Type"); 44 | let data = xhr.responseText; 45 | if (/\bjson\b/.test(type)) { 46 | data = JSON.parse(data); 47 | } 48 | resolve(data); 49 | } else { 50 | reject(xhr); 51 | } 52 | } 53 | }; 54 | 55 | xhr.send(); 56 | }); 57 | } 58 | 59 | export async function preloadOrAjax(url, settings) { 60 | const preloaded = Preload.get(url.replace(".json", "")); 61 | if (preloaded) { 62 | return preloaded; 63 | } else { 64 | return await ajax(url, settings); 65 | } 66 | } 67 | 68 | let HIDDEN_PROPERTY; 69 | let TITLE; 70 | let TITLE_COUNT; 71 | 72 | export function updateHiddenProperty(property) { 73 | HIDDEN_PROPERTY = property; 74 | } 75 | 76 | export function isHidden() { 77 | if (HIDDEN_PROPERTY !== undefined) { 78 | return document[HIDDEN_PROPERTY]; 79 | } else { 80 | return !document.hasFocus; 81 | } 82 | } 83 | 84 | export function increaseTitleCount(increment) { 85 | if (!isHidden()) { 86 | return; 87 | } 88 | 89 | TITLE ||= document.title; 90 | TITLE_COUNT ||= 0; 91 | TITLE_COUNT += increment; 92 | document.title = `${TITLE} (${TITLE_COUNT})`; 93 | } 94 | 95 | export function resetTitleCount() { 96 | TITLE_COUNT = 0; 97 | document.title = TITLE || document.title; 98 | } 99 | 100 | export function formatTime(timestamp) { 101 | const time = moment(timestamp); 102 | const now = moment(); 103 | 104 | if (time.diff(now.startOf("day")) > 0) { 105 | return time.format("h:mm a"); 106 | } else if (time.diff(now.startOf("week")) > 0) { 107 | return time.format("dd h:mm a"); 108 | } else if (time.diff(now.startOf("year")) > 0) { 109 | return time.format("D MMM h:mm a"); 110 | } else { 111 | return time.format("D MMM YY"); 112 | } 113 | } 114 | 115 | export function buildArrayString(array) { 116 | const buffer = array.map((v) => { 117 | if (v === null) { 118 | return "null"; 119 | } else if (Array.isArray(v)) { 120 | return buildArrayString(v); 121 | } else { 122 | return escapeHtml(v.toString()); 123 | } 124 | }); 125 | 126 | return "[" + buffer.join(", ") + "]"; 127 | } 128 | 129 | export function buildHashString(hash, recurse, expanded = []) { 130 | if (!hash) { 131 | return ""; 132 | } 133 | 134 | const buffer = []; 135 | const hashes = []; 136 | const expandableKeys = Preload.get("env_expandable_keys") || []; 137 | 138 | for (const [k, v] of Object.entries(hash)) { 139 | if (v === null) { 140 | buffer.push("null"); 141 | } else if (Object.prototype.toString.call(v) === "[object Array]") { 142 | let valueHtml = ""; 143 | if ( 144 | expandableKeys.includes(k) && 145 | !recurse && 146 | !expanded.includes(k) && 147 | v.length > 3 148 | ) { 149 | valueHtml = `${escapeHtml( 150 | v[0] 151 | )}, ${v.length - 1} more`; 152 | } else { 153 | valueHtml = `${escapeHtml(v[0])}, ${buildArrayString( 154 | v.slice(1, v.length) 155 | )}`; 156 | } 157 | buffer.push(`${escapeHtml(k)}${valueHtml}`); 158 | } else if (typeof v === "object") { 159 | hashes.push(k); 160 | } else { 161 | if (k === "time" && typeof v === "number") { 162 | const title = moment(v).format(); 163 | const time = formatTime(v); 164 | buffer.push(`${k}${time}`); 165 | } else { 166 | buffer.push( 167 | `${escapeHtml(k)}${escapeHtml(v)}` 168 | ); 169 | } 170 | } 171 | } 172 | 173 | for (const k1 of hashes) { 174 | const v = hash[k1]; 175 | buffer.push(""); 176 | buffer.push( 177 | `` 178 | ); 179 | buffer.push("
${escapeHtml(k1)}${buildHashString(v, true)}
"); 180 | } 181 | 182 | const className = recurse ? "" : "env-table"; 183 | return `${buffer.join("\n")}
`; 184 | } 185 | 186 | export function clone(object) { 187 | // simple function to clone an object 188 | // we don't need it fancier than this 189 | const copy = {}; 190 | for (const [k, v] of Object.entries(object)) { 191 | copy[k] = v; 192 | } 193 | return copy; 194 | } 195 | 196 | export function setLocalStorage(key, value) { 197 | try { 198 | if (window.localStorage) { 199 | key = "logster-" + key; 200 | window.localStorage.setItem(key, value); 201 | } 202 | } catch { 203 | /* do nothing */ 204 | } 205 | } 206 | 207 | export function getLocalStorage(key, fallback) { 208 | try { 209 | if (window.localStorage) { 210 | key = "logster-" + key; 211 | const value = window.localStorage.getItem(key); 212 | if (value === null) { 213 | // key doesn't exist 214 | return fallback; 215 | } 216 | if (value === "true") { 217 | return true; 218 | } 219 | if (value === "false") { 220 | return false; 221 | } 222 | // Add more cases here for numbers, null, undefined etc. as/when needed 223 | return value; 224 | } else { 225 | return fallback; 226 | } 227 | } catch { 228 | return fallback; 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /client-app/app/models/group.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import { reads } from "@ember/object/computed"; 3 | import Message from "client-app/models/message"; 4 | import EmberObject, { computed } from "@ember/object"; 5 | import { ajax } from "client-app/lib/utilities"; 6 | 7 | @classic 8 | export default class Group extends EmberObject { 9 | selected = false; 10 | showCount = true; 11 | 12 | @reads("regex") key; 13 | @reads("messages.firstObject.message") displayMessage; 14 | 15 | init() { 16 | super.init(...arguments); 17 | const messages = this.messages.map((m) => Message.create(m)); 18 | this.set("messages", messages); 19 | } 20 | 21 | @computed 22 | get glyph() { 23 | return "clone"; 24 | } 25 | 26 | @computed 27 | get prefix() { 28 | return "far"; 29 | } 30 | 31 | solveAll() { 32 | return ajax("/solve-group", { type: "POST", data: { regex: this.regex } }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /client-app/app/models/message.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import { gt } from "@ember/object/computed"; 3 | import EmberObject, { computed } from "@ember/object"; 4 | import { ajax } from "client-app/lib/utilities"; 5 | import { getRootPath } from "client-app/lib/preload"; 6 | 7 | @classic 8 | export default class Message extends EmberObject { 9 | MAX_LEN = 200; 10 | 11 | @gt("count", 1) showCount; 12 | 13 | @computed("MAX_LEN", "expanded", "message.length") 14 | get hasMore() { 15 | return !this.expanded && this.message.length > this.MAX_LEN; 16 | } 17 | 18 | @computed("key") 19 | get shareUrl() { 20 | return `${getRootPath()}/show/${this.key}`; 21 | } 22 | 23 | @computed("MAX_LEN", "expanded", "message.length") 24 | get displayMessage() { 25 | let message = this.message; 26 | 27 | if (!this.expanded && this.message.length > this.MAX_LEN) { 28 | message = this.message.substr(0, this.MAX_LEN); 29 | } 30 | return message; 31 | } 32 | 33 | @computed("backtrace.length", "env.{application_version,length}") 34 | get canSolve() { 35 | const appVersion = Array.isArray(this.env) 36 | ? this.env 37 | .map((e) => e.application_version) 38 | .compact() 39 | .join("") 40 | : this.env && this.env.application_version; 41 | return appVersion && this.backtrace && this.backtrace.length > 0; 42 | } 43 | 44 | @computed("severity") 45 | get rowClass() { 46 | switch (this.severity) { 47 | case 0: 48 | return "debug"; 49 | case 1: 50 | return "info"; 51 | case 2: 52 | return "warn"; 53 | case 3: 54 | return "error"; 55 | case 4: 56 | return "fatal"; 57 | default: 58 | return "unknown"; 59 | } 60 | } 61 | 62 | @computed("severity") 63 | get glyph() { 64 | switch (this.severity) { 65 | case 0: 66 | return ""; 67 | case 1: 68 | return ""; 69 | case 2: 70 | return "exclamation-circle"; 71 | case 3: 72 | return "times-circle"; 73 | case 4: 74 | return "times-circle"; 75 | default: 76 | return "question-circle"; 77 | } 78 | } 79 | 80 | get prefix() { 81 | return "fas"; 82 | } 83 | 84 | @computed("severity") 85 | get klass() { 86 | switch (this.severity) { 87 | case 0: 88 | return ""; 89 | case 1: 90 | return ""; 91 | case 2: 92 | return "warning"; 93 | case 3: 94 | return "error"; 95 | case 4: 96 | return "fatal"; 97 | default: 98 | return "unknown"; 99 | } 100 | } 101 | 102 | async fetchEnv() { 103 | const env = await ajax(`/fetch-env/${this.key}.json`); 104 | this.set("env", env); 105 | } 106 | 107 | expand() { 108 | this.set("expanded", true); 109 | } 110 | 111 | solve() { 112 | return ajax(`/solve/${this.key}`, { type: "PUT" }); 113 | } 114 | 115 | destroy() { 116 | return ajax(`/message/${this.key}`, { type: "DELETE" }); 117 | } 118 | 119 | protect() { 120 | this.set("protected", true); 121 | return ajax(`/protect/${this.key}`, { type: "PUT" }); 122 | } 123 | 124 | unprotect() { 125 | this.set("protected", false); 126 | return ajax(`/unprotect/${this.key}`, { type: "DELETE" }); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /client-app/app/models/pattern-item.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import { lte } from "@ember/object/computed"; 3 | import EmberObject, { computed } from "@ember/object"; 4 | 5 | @classic 6 | export default class PatternItem extends EmberObject { 7 | isNew = false; 8 | value = ""; 9 | valueBuffer = ""; 10 | error = null; 11 | saving = false; 12 | count = 0; 13 | 14 | @lte("count", 0) zeroCount; 15 | 16 | init() { 17 | super.init(...arguments); 18 | this.set("valueBuffer", this.value); 19 | } 20 | 21 | @computed("value", "valueBuffer") 22 | get hasBuffer() { 23 | return this.value !== this.valueBuffer; 24 | } 25 | 26 | updateValue(newValue) { 27 | this.setProperties({ 28 | value: newValue, 29 | valueBuffer: newValue, 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client-app/app/resolver.js: -------------------------------------------------------------------------------- 1 | import Resolver from "ember-resolver"; 2 | 3 | export default Resolver; 4 | -------------------------------------------------------------------------------- /client-app/app/router.js: -------------------------------------------------------------------------------- 1 | import EmberRouter from "@ember/routing/router"; 2 | import config from "client-app/config/environment"; 3 | 4 | export default class Router extends EmberRouter { 5 | location = config.locationType; 6 | rootURL = config.rootURL; 7 | } 8 | 9 | Router.map(function () { 10 | this.route("index", { path: "/" }); 11 | this.route("show", { path: "/show/:id" }); 12 | this.route("settings"); 13 | }); 14 | -------------------------------------------------------------------------------- /client-app/app/routes/index.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import { inject as service } from "@ember/service"; 3 | import Route from "@ember/routing/route"; 4 | import MessageCollection, { 5 | SEVERITIES, 6 | } from "client-app/models/message-collection"; 7 | import { isHidden } from "client-app/lib/utilities"; 8 | 9 | @classic 10 | export default class IndexRoute extends Route { 11 | @service events; 12 | 13 | model() { 14 | // TODO from preload json? 15 | return MessageCollection.create(); 16 | } 17 | 18 | setupController(controller, model) { 19 | super.setupController(controller, model); 20 | for (const severity of SEVERITIES) { 21 | model.set(`show${severity}`, controller[`show${severity}`]); 22 | } 23 | 24 | model.reload(); 25 | 26 | let times = 0; 27 | let backoff = 1; 28 | 29 | this.refreshInterval = setInterval(() => { 30 | if (model.loading) { 31 | return; 32 | } 33 | 34 | times += 1; 35 | const hidden = isHidden(); 36 | let load = !hidden; 37 | 38 | if (hidden) { 39 | if (times % backoff === 0) { 40 | load = true; 41 | if (backoff < 20) { 42 | backoff++; 43 | } 44 | } 45 | } 46 | 47 | // refresh a lot less aggressively in background 48 | if (load) { 49 | model.loadMore(); 50 | if (!hidden) { 51 | backoff = 1; 52 | } 53 | } 54 | }, 3000); 55 | 56 | this.events.on("panelResized", (amount) => { 57 | controller.resizePanels(amount); 58 | }); 59 | } 60 | 61 | deactivate() { 62 | clearInterval(this.refreshInterval); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /client-app/app/routes/settings.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import Route from "@ember/routing/route"; 3 | import { ajax } from "client-app/lib/utilities"; 4 | import Pattern from "client-app/models/pattern-item"; 5 | 6 | @classic 7 | export default class SettingsRoute extends Route { 8 | model() { 9 | return ajax("/settings.json"); 10 | } 11 | 12 | setupController(controller, model) { 13 | super.setupController(...arguments); 14 | const suppression = model.suppression; 15 | const codedSuppression = suppression 16 | .filter((p) => p.hard) 17 | .map((hash) => Pattern.create(hash)); 18 | 19 | const customSuppression = suppression 20 | .reject((p) => p.hard) 21 | .map((hash) => Pattern.create(hash)); 22 | 23 | const grouping = model.grouping.map((hash) => Pattern.create(hash)); 24 | const showCodedSuppression = codedSuppression.length > 0; 25 | controller.setProperties({ 26 | showCodedSuppression, 27 | codedSuppression, 28 | customSuppression, 29 | grouping, 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client-app/app/routes/show.js: -------------------------------------------------------------------------------- 1 | import classic from "ember-classic-decorator"; 2 | import Route from "@ember/routing/route"; 3 | import Message from "client-app/models/message"; 4 | import { preloadOrAjax } from "client-app/lib/utilities"; 5 | 6 | @classic 7 | export default class ShowRoute extends Route { 8 | model(params) { 9 | return preloadOrAjax("/show/" + params.id + ".json"); 10 | } 11 | 12 | setupController(controller, model) { 13 | super.setupController(...arguments); 14 | controller.set("model", Message.create(model)); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client-app/app/services/events.js: -------------------------------------------------------------------------------- 1 | import Service from "@ember/service"; 2 | import Evented from "@ember/object/evented"; 3 | 4 | export default class Events extends Service.extend(Evented) {} 5 | -------------------------------------------------------------------------------- /client-app/app/templates/application.hbs: -------------------------------------------------------------------------------- 1 | {{outlet}} -------------------------------------------------------------------------------- /client-app/app/templates/components/actions-menu.hbs: -------------------------------------------------------------------------------- 1 | {{#if @actionsInMenu}} 2 | {{#if this.showMenu}} 3 |
4 | {{yield}} 5 |
6 | {{/if}} 7 | 8 | 15 | 16 | {{#if @showShare}} 17 | 21 | {{/if}} 22 | {{else}} 23 | {{yield}} 24 | 25 | {{#if @showShare}} 26 | 30 | {{/if}} 31 | {{/if}} -------------------------------------------------------------------------------- /client-app/app/templates/components/back-to-site-link.hbs: -------------------------------------------------------------------------------- 1 | {{#if this.shouldDisplay}} 2 |
3 | 4 | 5 | {{@text}} 6 | 7 |
8 | {{/if}} -------------------------------------------------------------------------------- /client-app/app/templates/components/back-trace.hbs: -------------------------------------------------------------------------------- 1 | {{~#each this.lines as |line|~}} 2 |
3 | {{~line.line~}} 4 | {{~#if line.url~}} 5 | 11 | 12 | 13 | {{~/if~}} 14 |
15 | {{~/each~}} -------------------------------------------------------------------------------- /client-app/app/templates/components/env-tab.hbs: -------------------------------------------------------------------------------- 1 | {{#if this.isEnvArray}} 2 | 8 | {{/if}} 9 | 10 | {{this.html}} -------------------------------------------------------------------------------- /client-app/app/templates/components/message-info.hbs: -------------------------------------------------------------------------------- 1 |
2 | 3 | 9 | {{#if @showTitle}} 10 |

Message 11 | {{#if @currentMessage.showCount}} 12 | ({{@currentMessage.count}} 13 | copies reported) 14 | {{/if}} 15 |

16 | {{/if}} 17 | 18 |
{{@currentMessage.message}}
19 |
20 | 21 | 28 | {{#if @showTitle}} 29 |

Backtrace

30 | {{/if}} 31 | 32 |
37 |
38 | 39 | 46 | {{#if @currentMessage}} 47 | {{#if @currentMessage.env}} 48 | {{#if @showTitle}} 49 |

Env

50 | {{/if}} 51 | 52 | 57 | {{else if @loadingEnv}} 58 | Loading env... 59 | {{else}} 60 | No env for this message. 61 | {{/if}} 62 | {{/if}} 63 |
64 |
65 | 66 | {{#if @currentMessage}} 67 |
68 | 73 | {{#each this.buttons as |btn|}} 74 | 82 | {{/each}} 83 | 84 |
85 | {{/if}} 86 |
-------------------------------------------------------------------------------- /client-app/app/templates/components/message-row.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{#if @model.showCount}} 3 | {{@model.count}} 4 | {{/if}} 5 |
6 | 7 |
8 | {{#if @model.glyph}} 9 | 14 | {{/if}} 15 |
16 | 17 |
18 | {{@model.displayMessage}} 19 |
20 | 21 |
22 | {{#if @model.protected}} 23 | 27 | {{/if}} 28 |
29 | 30 |
31 | 32 |
-------------------------------------------------------------------------------- /client-app/app/templates/components/page-nav.hbs: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | 19 | {{this.displayNumber}}/{{@list.length}} 20 | 21 | 29 | 30 | -------------------------------------------------------------------------------- /client-app/app/templates/components/panel-resizer.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
-------------------------------------------------------------------------------- /client-app/app/templates/components/patterns-list.hbs: -------------------------------------------------------------------------------- 1 | {{#if @mutable}} 2 | 6 | {{/if}} 7 | 8 | {{#each this.allPatterns as |pattern|}} 9 |
10 | 17 | 18 | {{#if @mutable}} 19 | {{#if pattern.hasBuffer}} 20 | 28 | {{/if}} 29 | 30 | 38 | {{/if}} 39 | 40 | {{#if this.showCounter}} 41 | 47 | 56 | {{/if}} 57 |
58 | 59 | {{#if @applyRetroactivelyCheckbox}} 60 | {{#if pattern.isNew}} 61 |
62 | 68 | Apply retroactively 69 |
70 | {{/if}} 71 | {{/if}} 72 | 73 | {{~#if pattern.error~}} 74 |
75 |       {{~pattern.error~}}
76 |     
77 | {{~/if~}} 78 | {{/each}} -------------------------------------------------------------------------------- /client-app/app/templates/components/tabbed-section.hbs: -------------------------------------------------------------------------------- 1 | {{yield 2 | (hash addTab=this.addTab removeTab=this.removeTab selectTab=this.selectTab) 3 | }} 4 | 5 | -------------------------------------------------------------------------------- /client-app/app/templates/components/time-formatter.hbs: -------------------------------------------------------------------------------- 1 | {{this.time}} -------------------------------------------------------------------------------- /client-app/app/templates/index.hbs: -------------------------------------------------------------------------------- 1 | {{#if this.hasTopMenu}} 2 |
3 | 7 |
8 | {{/if}} 9 |
10 |
11 | {{#if this.model.moreBefore}} 12 |
13 | {{#if this.model.hideCountInLoadMore}} 14 | Load more 15 | {{else}} 16 | Select to see 17 | {{this.model.totalBefore}} 18 | more 19 | {{/if}} 20 |
21 | {{/if}} 22 | 23 | {{#if this.loading}} 24 |
25 | {{/if}} 26 | 27 | {{#each this.model.rows as |row|}} 28 |
29 | {{#if this.buildingGroupingPattern}} 30 | 35 | {{/if}} 36 | 37 |
38 | {{/each}} 39 |
40 |
41 | 42 |
43 | {{#if this.model.currentRow.group}} 44 | 50 | {{/if}} 51 | 52 | 64 | 65 |
66 |
67 |
68 | 76 | 77 | 85 | 86 | 95 | 96 | 105 | 106 | 115 |
116 |
117 | 118 |
119 | 126 | 127 | 161 |
162 |
163 |
164 | 165 | -------------------------------------------------------------------------------- /client-app/app/templates/settings.hbs: -------------------------------------------------------------------------------- 1 |
2 | Home 3 | 4 |
5 |

Settings

6 | 7 |
8 | 9 |
10 |

Suppression Patterns

11 | 12 |
New messages that match these Regular Expression patterns will be 13 | suppressed. Checking Apply retroactively will remove all existing messages 14 | that match the patterns.
15 | 16 | {{#if this.showCodedSuppression}} 17 |

Hard-coded patterns:

18 | 19 |
These patterns can't be removed via the UI because they 20 | are commited to the source code of your app.
21 | 22 | 23 | {{/if}} 24 | 25 |

Custom patterns:

26 | 27 | 33 |
34 | 35 |
36 |

Grouping Patterns

37 | 38 |
Add a Regular Expression pattern to group all new and existing messages 39 | into a single row when viewing the logs.
40 | 41 | 46 |
47 |
-------------------------------------------------------------------------------- /client-app/app/templates/show.hbs: -------------------------------------------------------------------------------- 1 | Recent 2 | 3 |
4 | 13 |
-------------------------------------------------------------------------------- /client-app/config/ember-cli-update.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": "1.0.0", 3 | "packages": [ 4 | { 5 | "name": "ember-cli", 6 | "version": "3.28.6", 7 | "blueprints": [ 8 | { 9 | "name": "app", 10 | "outputRepo": "https://github.com/ember-cli/ember-new-output", 11 | "codemodsSource": "ember-app-codemods-manifest@1", 12 | "isBaseBlueprint": true, 13 | "options": ["--no-welcome"] 14 | } 15 | ] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /client-app/config/environment.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = function (environment) { 4 | let ENV = { 5 | modulePrefix: "client-app", 6 | environment, 7 | rootURL: "/logs", 8 | locationType: "history", 9 | EmberENV: { 10 | FEATURES: { 11 | // Here you can enable experimental features on an ember canary build 12 | // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true 13 | }, 14 | EXTEND_PROTOTYPES: { 15 | // Prevent Ember Data from overriding Date.parse. 16 | Date: false, 17 | }, 18 | }, 19 | 20 | APP: { 21 | // Here you can pass flags/options to your application instance 22 | // when it is created 23 | }, 24 | }; 25 | 26 | if (environment === "development") { 27 | // ENV.APP.LOG_RESOLVER = true; 28 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 29 | // ENV.APP.LOG_TRANSITIONS = true; 30 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 31 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 32 | } 33 | 34 | if (environment === "test") { 35 | // Testem prefers this... 36 | ENV.locationType = "none"; 37 | 38 | // keep test console output quieter 39 | ENV.APP.LOG_ACTIVE_GENERATION = false; 40 | ENV.APP.LOG_VIEW_LOOKUPS = false; 41 | 42 | ENV.APP.rootElement = "#ember-testing"; 43 | ENV.APP.autoboot = false; 44 | } 45 | 46 | if (environment === "production") { 47 | // here you can enable a production-specific feature 48 | } 49 | 50 | return ENV; 51 | }; 52 | -------------------------------------------------------------------------------- /client-app/config/icons.js: -------------------------------------------------------------------------------- 1 | module.exports = function () { 2 | return { 3 | "free-solid-svg-icons": [ 4 | "arrow-left", 5 | "check-square", 6 | "trash-alt", 7 | "ellipsis-h", 8 | "share", 9 | "external-link-square-alt", 10 | "fast-backward", 11 | "backward", 12 | "fast-forward", 13 | "forward", 14 | "plus", 15 | "check", 16 | "redo-alt", 17 | "exclamation-circle", 18 | "times-circle", 19 | "question-circle", 20 | "cog", 21 | "lock", 22 | "unlock", 23 | "list", 24 | ], 25 | "free-regular-svg-icons": ["trash-alt", "check-square", "copy", "clone"], 26 | // "free-brands-svg-icons": [] 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /client-app/config/optional-features.json: -------------------------------------------------------------------------------- 1 | { 2 | "application-template-wrapper": false, 3 | "default-async-observers": true, 4 | "jquery-integration": false, 5 | "template-only-glimmer-components": true 6 | } 7 | -------------------------------------------------------------------------------- /client-app/config/targets.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const browsers = [ 4 | "last 1 Chrome versions", 5 | "last 1 Firefox versions", 6 | "last 1 Safari versions", 7 | ]; 8 | 9 | // Ember's browser support policy is changing, and IE11 support will end in 10 | // v4.0 onwards. 11 | // 12 | // See https://deprecations.emberjs.com/v3.x#toc_3-0-browser-support-policy 13 | // 14 | // If you need IE11 support on a version of Ember that still offers support 15 | // for it, uncomment the code block below. 16 | 17 | const isCI = Boolean(process.env.CI); 18 | const isProduction = process.env.EMBER_ENV === "production"; 19 | 20 | if (isCI || isProduction) { 21 | browsers.push("ie 11"); 22 | } 23 | 24 | module.exports = { 25 | browsers, 26 | }; 27 | -------------------------------------------------------------------------------- /client-app/ember-cli-build.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const EmberApp = require("ember-cli/lib/broccoli/ember-app"); 4 | 5 | module.exports = function (defaults) { 6 | let app = new EmberApp(defaults, { 7 | // Add options here 8 | fingerprint: { 9 | enabled: false, 10 | }, 11 | }); 12 | 13 | // Use `app.import` to add additional libraries to the generated 14 | // output files. 15 | // 16 | // If you need to use different assets in different 17 | // environments, specify an object as the first parameter. That 18 | // object's keys should be the environment name and the values 19 | // should be the asset to use in that environment. 20 | // 21 | // If the library that you are including contains AMD or ES6 22 | // modules that you would like to import into your application 23 | // please specify an object with the list of modules as keys 24 | // along with the exports of each module as its value. 25 | 26 | app.import("node_modules/moment/min/moment.min.js"); 27 | return app.toTree(); 28 | }; 29 | -------------------------------------------------------------------------------- /client-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client-app", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Logging framework and viewer", 6 | "repository": "https://github.com/discourse/logster/", 7 | "license": "MIT", 8 | "author": "Sam Saffron", 9 | "directories": { 10 | "doc": "doc", 11 | "test": "tests" 12 | }, 13 | "scripts": { 14 | "build": "ember build --environment=production", 15 | "lint": "npm-run-all --aggregate-output --continue-on-error --parallel \"lint:!(fix)\"", 16 | "lint:fix": "npm-run-all --aggregate-output --continue-on-error --parallel lint:*:fix", 17 | "lint:hbs": "ember-template-lint .", 18 | "lint:hbs:fix": "ember-template-lint . --fix", 19 | "lint:js": "eslint . --cache", 20 | "lint:js:fix": "eslint . --fix", 21 | "start": "ember serve", 22 | "test": "npm-run-all lint test:*", 23 | "test:ember": "ember test" 24 | }, 25 | "devDependencies": { 26 | "@ember/optional-features": "^2.0.0", 27 | "@ember/test-helpers": "^2.6.0", 28 | "@fortawesome/ember-fontawesome": "^0.4.1", 29 | "@fortawesome/free-brands-svg-icons": "^6.3.0", 30 | "@fortawesome/free-regular-svg-icons": "^6.3.0", 31 | "@fortawesome/free-solid-svg-icons": "^6.3.0", 32 | "@glimmer/component": "^1.0.4", 33 | "@glimmer/tracking": "^1.0.4", 34 | "babel-eslint": "^10.1.0", 35 | "broccoli-asset-rev": "^3.0.0", 36 | "ember-auto-import": "^2.6.1", 37 | "ember-classic-decorator": "^3.0.1", 38 | "ember-cli": "^3.28.6", 39 | "ember-cli-app-version": "^6.0.0", 40 | "ember-cli-babel": "^7.26.10", 41 | "ember-cli-dependency-checker": "^3.2.0", 42 | "ember-cli-htmlbars": "^6.2.0", 43 | "ember-cli-inject-live-reload": "^2.1.0", 44 | "ember-cli-sri": "^2.1.1", 45 | "ember-cli-terser": "^4.0.2", 46 | "ember-decorators": "^6.1.1", 47 | "ember-export-application-global": "^2.0.1", 48 | "ember-load-initializers": "^2.1.2", 49 | "ember-maybe-import-regenerator": "^1.0.0", 50 | "ember-page-title": "^7.0.0", 51 | "ember-qunit": "^6.2.0", 52 | "ember-resolver": "^10.0.0", 53 | "ember-sinon-qunit": "^7.0.0", 54 | "ember-source": "^3.28.11", 55 | "eslint-config-discourse": "^3.4.0", 56 | "loader.js": "^4.7.0", 57 | "npm-run-all": "^4.1.5", 58 | "qunit": "^2.17.2", 59 | "qunit-dom": "^2.0.0", 60 | "sinon": "^15.0.1", 61 | "webpack": "^5.94.0" 62 | }, 63 | "engines": { 64 | "node": "12.* || 14.* || >= 16" 65 | }, 66 | "ember": { 67 | "edition": "octane" 68 | }, 69 | "dependencies": { 70 | "moment": "~2.29.4" 71 | }, 72 | "overrides": { 73 | "testem": "^3.9.0", 74 | "workerpool": "^6.3.1" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /client-app/preload-json-manager.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This script takes care of updating the content of the preloaded json in tests/index.html 4 | # All you need to do is update the `tests_index_html` hash and run the script 5 | 6 | require "bundler/inline" 7 | require "json" 8 | require "cgi" 9 | 10 | gemfile do 11 | source "https://rubygems.org" 12 | gem "nokogiri" 13 | end 14 | 15 | tests_index_html = { 16 | env_expandable_keys: [], 17 | gems_dir: "/var/www/discourse/vendor/bundle/ruby/2.6.0/gems/", 18 | backtrace_links_enabled: true, 19 | gems_data: [ 20 | { name: "activerecord", url: "https://github.com/rails/rails/tree/v6.0.1/activerecord" }, 21 | ], 22 | directories: [ 23 | { path: "/var/www/discourse", url: "https://github.com/discourse/discourse", main_app: true }, 24 | { 25 | path: "/var/www/discourse/plugins/discourse-prometheus", 26 | url: "https://github.com/discourse/discourse-prometheus", 27 | }, 28 | ], 29 | application_version: "ce512452b512b909c38e9c63f2a0e1f8c17a2399", 30 | } 31 | 32 | content = File.read("tests/index.html") 33 | json = CGI.escapeHTML(JSON.generate(tests_index_html)) 34 | content.sub!(/data-preloaded=".*">$/, "data-preloaded=\"#{json}\">") 35 | File.write("tests/index.html", content) 36 | -------------------------------------------------------------------------------- /client-app/public/assets/images/icon_144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/logster/4209863d0c9ad8f52ec94c11f6d9215cb9885b65/client-app/public/assets/images/icon_144x144.png -------------------------------------------------------------------------------- /client-app/public/assets/images/icon_64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/logster/4209863d0c9ad8f52ec94c11f6d9215cb9885b65/client-app/public/assets/images/icon_64x64.png -------------------------------------------------------------------------------- /client-app/testem.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | test_page: "tests/index.html?hidepassed", 5 | disable_watching: true, 6 | launch_in_ci: ["Chrome"], 7 | launch_in_dev: ["Chrome"], 8 | browser_start_timeout: 120, 9 | browser_args: { 10 | Chrome: { 11 | ci: [ 12 | // --no-sandbox is needed when running Chrome inside a container 13 | process.env.CI ? "--no-sandbox" : null, 14 | "--headless", 15 | "--disable-dev-shm-usage", 16 | "--disable-software-rasterizer", 17 | "--mute-audio", 18 | "--remote-debugging-port=0", 19 | "--window-size=1440,900", 20 | ].filter(Boolean), 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /client-app/tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ClientApp Tests 7 | 8 | 9 | 10 | 11 | {{content-for "head"}} 12 | {{content-for "test-head"}} 13 | 14 | 15 | 16 | 17 | 18 | {{content-for "head-footer"}} 19 | {{content-for "test-head-footer"}} 20 | 21 | 22 | {{content-for "body"}} 23 | {{content-for "test-body"}} 24 | 25 |
26 |
27 |
28 |
29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {{content-for "body-footer"}} 39 | {{content-for "test-body-footer"}} 40 | 41 | 42 | -------------------------------------------------------------------------------- /client-app/tests/integration/components/back-to-site-link-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from "qunit"; 2 | import { setupRenderingTest } from "ember-qunit"; 3 | import { render } from "@ember/test-helpers"; 4 | import hbs from "htmlbars-inline-precompile"; 5 | 6 | module("Integration | Component | back-to-site-link", function (hooks) { 7 | setupRenderingTest(hooks); 8 | 9 | test("With path and text paremeter", async function (assert) { 10 | await render(hbs``); 11 | assert.dom("#back-to-site-panel a").exists("It shows back to site link"); 12 | }); 13 | 14 | test("Without required paremeters", async function (assert) { 15 | await render(hbs``); 16 | assert 17 | .dom("#back-to-site-panel a") 18 | .doesNotExist("It does not show back link to site"); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /client-app/tests/integration/components/back-trace-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from "qunit"; 2 | import { setupRenderingTest } from "ember-qunit"; 3 | import { find, findAll, render } from "@ember/test-helpers"; 4 | import hbs from "htmlbars-inline-precompile"; 5 | import { mutatePreload, uninitialize } from "client-app/lib/preload"; 6 | 7 | module("Integration | Component | back-trace", function (hooks) { 8 | setupRenderingTest(hooks); 9 | 10 | hooks.beforeEach(function () { 11 | uninitialize(); 12 | }); 13 | 14 | hooks.afterEach(function () { 15 | uninitialize(); 16 | }); 17 | 18 | test("backtrace lines display and work correctly", async function (assert) { 19 | const backtrace = `/var/www/discourse/vendor/bundle/ruby/2.6.0/gems/activerecord-6.0.1/lib/active_record/relation/finder_methods.rb:317:in \`exists?' 20 | /var/www/discourse/lib/permalink_constraint.rb:6:in \`matches?' 21 | /var/www/discourse/plugins/discourse-prometheus/lib/middleware/metrics.rb:17:in \`call' 22 | activerecord-6.0.1/lib/active_record/relation/finder_methods.rb:317:in \`exists?'`; 23 | this.set("backtrace", backtrace); 24 | await render(hbs``); 25 | 26 | const [gem, app, plugin, gem2] = findAll("a"); 27 | assert.strictEqual( 28 | gem.href, 29 | "https://github.com/rails/rails/tree/v6.0.1/activerecord/lib/active_record/relation/finder_methods.rb#L317" 30 | ); 31 | 32 | assert.strictEqual( 33 | app.href, 34 | "https://github.com/discourse/discourse/blob/ce512452b512b909c38e9c63f2a0e1f8c17a2399/lib/permalink_constraint.rb#L6" 35 | ); 36 | 37 | assert.strictEqual( 38 | plugin.href, 39 | "https://github.com/discourse/discourse-prometheus/blob/master/lib/middleware/metrics.rb#L17" 40 | ); 41 | 42 | assert.strictEqual( 43 | gem2.href, 44 | "https://github.com/rails/rails/tree/v6.0.1/activerecord/lib/active_record/relation/finder_methods.rb#L317" 45 | ); 46 | 47 | let gemLine = find("div.backtrace-line"); 48 | assert.strictEqual( 49 | gemLine.textContent.trim(), 50 | "activerecord-6.0.1/lib/active_record/relation/finder_methods.rb:317:in `exists?'", 51 | "gem lines are truncated" 52 | ); 53 | }); 54 | 55 | test("non-ruby backtraces don't break things", async function (assert) { 56 | this.set( 57 | "backtrace", 58 | `m/<@https://discourse-cdn.com/assets/application-f59d2.br.js:1:27448 59 | m@https://discourse-cdn.com/assets/application-f59d2.br.js:1:27560 60 | string@https://discourse-cdn.com/assets/application-f59d2.br.js:1:27869` 61 | ); 62 | await render(hbs``); 63 | const lines = this.backtrace.split("\n"); 64 | findAll("div.backtrace-line").forEach((node, index) => { 65 | assert.strictEqual(node.textContent.trim(), lines[index]); 66 | }); 67 | }); 68 | 69 | test("non-gem backtraces don't break things", async function (assert) { 70 | this.set( 71 | "backtrace", 72 | `/ruby/gems/activesupport-7.0.4.1/lib/active_support/deprecation/behaviors.rb:33:in \`block in ' 73 | /ruby/gems/activesupport-7.0.4.1/lib/active_support/deprecation/reporting.rb:26:in \`block (2 levels) in warn' 74 | /ruby/gems/activesupport-7.0.4.1/lib/active_support/deprecation/reporting.rb:26:in \`each' 75 | /ruby/gems/activesupport-7.0.4.1/lib/active_support/deprecation/reporting.rb:26:in \`block in warn' 76 | :90:in \`tap' 77 | /ruby/gems/activesupport-7.0.4.1/lib/active_support/deprecation/reporting.rb:22:in \`warn'` 78 | ); 79 | await render(hbs``); 80 | const lines = this.backtrace.split("\n"); 81 | findAll("div.backtrace-line").forEach((node, index) => { 82 | assert.strictEqual(node.textContent.trim(), lines[index]); 83 | }); 84 | }); 85 | 86 | test("Github links use commit sha", async function (assert) { 87 | const backtrace = `/var/www/discourse/lib/permalink_constraint.rb:6:in \`matches?'`; 88 | let env = [ 89 | { application_version: "123abc" }, 90 | { application_version: "abc123" }, 91 | ]; 92 | this.setProperties({ 93 | backtrace, 94 | env, 95 | }); 96 | await render( 97 | hbs`` 98 | ); 99 | let href = find("a").href; 100 | assert.strictEqual( 101 | href, 102 | "https://github.com/discourse/discourse/blob/123abc/lib/permalink_constraint.rb#L6", 103 | "uses the first application_version if there are multiple versions" 104 | ); 105 | 106 | env = { application_version: "567def" }; 107 | this.set("env", env); 108 | await render( 109 | hbs`` 110 | ); 111 | href = find("a").href; 112 | assert.strictEqual( 113 | href, 114 | "https://github.com/discourse/discourse/blob/567def/lib/permalink_constraint.rb#L6", 115 | "uses application_version when env is only a hash" 116 | ); 117 | 118 | this.set("env", null); 119 | await render( 120 | hbs`` 121 | ); 122 | href = find("a").href; 123 | assert.strictEqual( 124 | href, 125 | "https://github.com/discourse/discourse/blob/ce512452b512b909c38e9c63f2a0e1f8c17a2399/lib/permalink_constraint.rb#L6", 126 | "falls back to preload if env doesn't contain application_version" 127 | ); 128 | 129 | mutatePreload("application_version", null); 130 | await render(hbs``); 131 | href = find("a").href; 132 | assert.strictEqual( 133 | href, 134 | "https://github.com/discourse/discourse/blob/master/lib/permalink_constraint.rb#L6", 135 | "falls back to master branch when neither preload nor application_version in env are available" 136 | ); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /client-app/tests/integration/components/env-tab-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from "qunit"; 2 | import { setupRenderingTest } from "ember-qunit"; 3 | import { click, find, findAll, render } from "@ember/test-helpers"; 4 | import hbs from "htmlbars-inline-precompile"; 5 | import Message from "client-app/models/message"; 6 | import { init } from "client-app/lib/preload"; 7 | 8 | const time1 = new Date("2010-01-01T01:00:00").getTime(); 9 | const time2 = new Date("2015-01-01T01:00:00").getTime(); 10 | 11 | const message = Message.create({ 12 | env: [ 13 | { a: "aa", b: "bb", time: time1 }, 14 | { c: "cc", d: "dd", time: time2 }, 15 | ], 16 | }); 17 | 18 | const message2 = Message.create({ 19 | env: { e: "ee", f: "ff" }, 20 | }); 21 | 22 | const message3 = Message.create({ 23 | env: [ 24 | { env_key_2: "value1", default_expanded: "vvv1", notExpanded: "dsdcz" }, 25 | { env_key_2: "value2", default_expanded: "vvv2", notExpanded: "uerue" }, 26 | { env_key_2: "value3", notExpanded: "weeww" }, 27 | { env_key_2: "value4", notExpanded: "cxc" }, 28 | ], 29 | }); 30 | 31 | const message4 = Message.create({ 32 | env: { env_key_2: "value", default_expanded: "vvv", notExpanded: "wwww" }, 33 | }); 34 | 35 | function reduceToContent(node) { 36 | return Array.from(node.childNodes).reduce( 37 | (ac, cr) => `${ac.textContent}: ${cr.textContent}` 38 | ); 39 | } 40 | 41 | module("Integration | Component | env-tab", function (hooks) { 42 | setupRenderingTest(hooks); 43 | 44 | test("it renders", async function (assert) { 45 | const callback = (newPosition) => this.set("envPosition", newPosition); 46 | this.setProperties({ 47 | message, 48 | callback, 49 | envPosition: 0, 50 | }); 51 | await render( 52 | hbs`` 53 | ); 54 | 55 | assert.strictEqual( 56 | find(".current-number").textContent, 57 | "1/2", 58 | "shows the current over the total number of env objects" 59 | ); 60 | let trs = findAll("tr"); 61 | assert.strictEqual(trs.length, 3); 62 | assert.strictEqual( 63 | reduceToContent(trs[0]), 64 | "a: aa", 65 | "has the right content" 66 | ); 67 | assert.strictEqual( 68 | reduceToContent(trs[1]), 69 | "b: bb", 70 | "has the right content" 71 | ); 72 | assert.strictEqual( 73 | reduceToContent(trs[2]), 74 | "time: 1 Jan 10", 75 | "has the right content" 76 | ); 77 | 78 | const buttons = findAll("button.nav-btn"); 79 | // at first page, you can't go back 80 | assert.ok(buttons[0].disabled, "back buttons are disabled"); 81 | assert.ok(buttons[1].disabled, "back buttons are disabled"); 82 | 83 | assert.notOk(buttons[2].disabled, "forward buttons are not disabled"); 84 | assert.notOk(buttons[3].disabled, "forward buttons are not disabled"); 85 | 86 | this.set("message", message2); 87 | assert.dom("button").doesNotExist("doesn't show buttons for non-array env"); 88 | 89 | trs = findAll("tr"); 90 | assert.strictEqual(trs.length, 2); 91 | assert.strictEqual( 92 | reduceToContent(trs[0]), 93 | "e: ee", 94 | "has the right content" 95 | ); 96 | assert.strictEqual( 97 | reduceToContent(trs[1]), 98 | "f: ff", 99 | "has the right content" 100 | ); 101 | }); 102 | 103 | test("it works correctly", async function (assert) { 104 | const callback = (newPosition) => this.set("envPosition", newPosition); 105 | this.setProperties({ 106 | message, 107 | callback, 108 | envPosition: 0, 109 | }); 110 | await render( 111 | hbs`` 112 | ); 113 | 114 | const buttons = findAll("button.nav-btn"); 115 | await click(buttons[2]); 116 | 117 | assert.strictEqual( 118 | find(".current-number").textContent, 119 | "2/2", 120 | "shows the current over the total number of env objects" 121 | ); 122 | 123 | const trs = findAll("tr"); 124 | assert.strictEqual(trs.length, 3); 125 | assert.strictEqual( 126 | reduceToContent(trs[0]), 127 | "c: cc", 128 | "has the right content" 129 | ); 130 | assert.strictEqual( 131 | reduceToContent(trs[1]), 132 | "d: dd", 133 | "has the right content" 134 | ); 135 | assert.strictEqual( 136 | reduceToContent(trs[2]), 137 | "time: 1 Jan 15", 138 | "has the right content" 139 | ); 140 | 141 | // at last page, you can't go forward but you can go back 142 | assert.notOk(buttons[0].disabled, "back buttons are not disabled"); 143 | assert.notOk(buttons[1].disabled, "back buttons are not disabled"); 144 | 145 | assert.ok(buttons[2].disabled, "forward buttons are disabled"); 146 | assert.ok(buttons[3].disabled, "forward buttons are disabled"); 147 | }); 148 | 149 | test("expandable env keys", async function (assert) { 150 | document.getElementById("preloaded-data").dataset.preloaded = 151 | JSON.stringify({ 152 | env_expandable_keys: ["env_key_2", "default_expanded"], 153 | }); 154 | init(); 155 | const callback = (newPosition) => this.set("envPosition", newPosition); 156 | this.setProperties({ 157 | message: message3, 158 | callback, 159 | envPosition: 0, 160 | }); 161 | await render( 162 | hbs`` 163 | ); 164 | 165 | const trs = findAll(".env-table tr"); 166 | const expandable = trs[0]; 167 | const defaultExpanded = trs[1]; 168 | 169 | assert.strictEqual( 170 | expandable.children[1].textContent.trim(), 171 | "value1, 3 more", 172 | "expandable env keys shown correctly" 173 | ); 174 | 175 | assert.strictEqual( 176 | defaultExpanded.children[1].textContent.trim(), 177 | "vvv1, [vvv2]", 178 | "list is expanded by default when its length is 3 or less" 179 | ); 180 | 181 | assert.strictEqual( 182 | findAll("a.expand-list").length, 183 | 1, 184 | "only whitelisted env keys are expandable" 185 | ); 186 | 187 | const expandBtn = find("a.expand-list"); 188 | assert.strictEqual(expandBtn.textContent.trim(), "3 more"); 189 | await click(expandBtn); 190 | 191 | const expanded = find(".env-table tr"); 192 | assert.strictEqual( 193 | expanded.children[1].textContent.trim(), 194 | "value1, [value2, value3, value4]", 195 | "expanded env keys shown correctly" 196 | ); 197 | 198 | this.setProperties({ 199 | message: message4, 200 | callback, 201 | envPosition: 0, 202 | }); 203 | await render( 204 | hbs`` 205 | ); 206 | const recreatedEnv = {}; 207 | findAll(".env-table tr").forEach((node) => { 208 | recreatedEnv[node.children[0].innerText.trim()] = 209 | node.children[1].innerText.trim(); 210 | }); 211 | 212 | for (const [k, v] of Object.entries(recreatedEnv)) { 213 | assert.strictEqual( 214 | v, 215 | this.message.env[k], 216 | `${k}: ${v} === ${this.message.env[k]}` 217 | ); 218 | } 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /client-app/tests/integration/components/message-info-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from "qunit"; 2 | import { setupRenderingTest } from "ember-qunit"; 3 | import { click, find, findAll, render } from "@ember/test-helpers"; 4 | import hbs from "htmlbars-inline-precompile"; 5 | import Message from "client-app/models/message"; 6 | 7 | const backtrace = "test backtrace:26"; 8 | const messageTitle = "This Is Title"; 9 | 10 | const message = Message.create({ 11 | backtrace, 12 | message: messageTitle, 13 | env: { c: "cc", d: "dd" }, 14 | }); 15 | 16 | module("Integration | Component | message-info", function (hooks) { 17 | setupRenderingTest(hooks); 18 | 19 | test("it renders", async function (assert) { 20 | const callback = (newPosition) => 21 | this.set("currentEnvPosition", newPosition); 22 | this.setProperties({ 23 | actionsInMenu: true, 24 | showTitle: false, 25 | envPosition: 0, 26 | message, 27 | callback, 28 | }); 29 | 30 | await render( 31 | hbs`` 39 | ); 40 | let activeTab = find(".message-info .content.active pre"); 41 | assert.strictEqual( 42 | activeTab.textContent.trim(), 43 | backtrace, 44 | "default active tab is backtrace" 45 | ); 46 | assert.dom(".message-info .content h3").doesNotExist("no titles are shown"); 47 | assert.strictEqual(findAll(".tabs a").length, 3, "3 tabs shown"); 48 | assert.strictEqual( 49 | find(".tabs a.active").textContent.trim(), 50 | "backtrace", 51 | "default active tab is backtrace" 52 | ); 53 | assert.strictEqual( 54 | findAll(".message-actions button").length, 55 | 2, 56 | "2 buttons shown when `actionsInMenu` is true" 57 | ); 58 | assert 59 | .dom(".message-actions button.expand.no-text") 60 | .exists("menu expand button is shown"); 61 | assert.dom(".message-actions button.share").exists("share button is shown"); 62 | 63 | await click(find(".message-actions button.expand.no-text")); 64 | assert.strictEqual( 65 | findAll(".actions-menu button").length, 66 | 3, 67 | "extra buttons shown inside a menu" 68 | ); 69 | assert 70 | .dom(".actions-menu button.remove") 71 | .exists("remove button inside the menu"); 72 | assert 73 | .dom(".actions-menu button.protect") 74 | .exists("protect button inside the menu"); 75 | 76 | this.setProperties({ 77 | showTitle: true, 78 | actionsInMenu: false, 79 | }); 80 | 81 | assert.strictEqual( 82 | findAll(".message-info .content h3").length, 83 | 3, 84 | "titles are shown" 85 | ); 86 | assert 87 | .dom(".message-actions button.expand.no-text") 88 | .doesNotExist("menu expand button is not shown"); 89 | assert.strictEqual( 90 | findAll(".message-actions button").length, 91 | 4, 92 | "all actions buttons are shown inline when `actionsInMenu` is false" 93 | ); 94 | 95 | await click(findAll(".tabs a")[0]); 96 | activeTab = find(".message-info .content.active pre"); 97 | assert.strictEqual(activeTab.textContent, messageTitle, "can switch tabs"); 98 | 99 | assert 100 | .dom(".message-actions button.solve") 101 | .doesNotExist( 102 | "no solve button when there is no application_version in env" 103 | ); 104 | 105 | message.set("env.application_version", "fddfsdfdsf"); 106 | this.set("message", message); 107 | assert 108 | .dom(".message-actions button.solve") 109 | .exists("solve button is shown when there is application_version in env"); 110 | 111 | message.set("env", [ 112 | { sd: "dx", application_version: "fsfdsf" }, 113 | { vcv: "dxc" }, 114 | ]); 115 | this.set("message", message); 116 | assert 117 | .dom(".message-actions button.solve") 118 | .exists( 119 | "solve button is shown when there is application_version in env (array)" 120 | ); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /client-app/tests/integration/components/patterns-list-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from "qunit"; 2 | import { setupRenderingTest } from "ember-qunit"; 3 | import hbs from "htmlbars-inline-precompile"; 4 | import { fillIn, findAll, render } from "@ember/test-helpers"; 5 | import Pattern from "client-app/models/pattern-item"; 6 | 7 | module("Integration | Component | patterns-list", function (hooks) { 8 | setupRenderingTest(hooks); 9 | 10 | test("it renders", async function (assert) { 11 | this.setProperties({ 12 | mutable: true, 13 | patterns: [], 14 | }); 15 | await render( 16 | hbs`` 17 | ); 18 | 19 | assert 20 | .dom(".pattern-input") 21 | .exists("It shows an input when patterns are emtpy"); 22 | assert 23 | .dom(".btn.new-pattern") 24 | .exists("It shows a create button when mutable"); 25 | 26 | const pattern1 = Pattern.create({ value: "/somepattern/" }); 27 | const pattern2 = Pattern.create({ value: "/anotherpattern/" }); 28 | this.set("patterns", [pattern1, pattern2]); 29 | assert.strictEqual( 30 | findAll(".pattern-input").length, 31 | 3, // yes 3 because there is always an empty pattern input 32 | "It correctly displays patterns" 33 | ); 34 | assert 35 | .dom(".btn.save") 36 | .doesNotExist("No save buttons are shown when there is 0 buffer"); 37 | const counters = findAll("input.count"); 38 | assert.strictEqual(counters.length, 3, "counters shown for all patterns"); 39 | assert.ok( 40 | counters.every((c) => c.disabled), 41 | "counters are disabled" 42 | ); 43 | 44 | pattern1.set("count", 6); 45 | this.set("patterns", [pattern1, pattern2]); 46 | const counterPresent = !!findAll("input.count").find( 47 | (c) => c.value === "6" 48 | ); 49 | assert.ok(counterPresent, "counter shows correct value"); 50 | assert.dom(".btn.reset").exists("Reset button is shown"); 51 | 52 | let inputs = findAll(".pattern-input"); 53 | await fillIn(inputs[0], "/newpattern/"); 54 | await fillIn(inputs[2], "/anothernewpattern/"); 55 | 56 | assert 57 | .dom(".btn.save") 58 | .exists("Save buttons are shown when there is buffer"); 59 | assert.dom(".btn.trash").exists("Trash buttons are shown"); 60 | 61 | let disabled = inputs.every((inp) => inp.disabled); 62 | assert.notOk( 63 | disabled, 64 | "All inputs are not disabled when the list is mutable" 65 | ); 66 | 67 | this.set("mutable", false); 68 | 69 | inputs = findAll(".pattern-input"); 70 | disabled = inputs.every((inp) => inp.disabled); 71 | assert.ok(disabled, "All inputs are disabled when the list is immutable"); 72 | assert 73 | .dom(".btn.trash") 74 | .doesNotExist("Trash buttons are not shown when the list is immutable"); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /client-app/tests/test-helper.js: -------------------------------------------------------------------------------- 1 | import Application from "client-app/app"; 2 | import config from "client-app/config/environment"; 3 | import * as QUnit from "qunit"; 4 | import { setApplication } from "@ember/test-helpers"; 5 | import { setup } from "qunit-dom"; 6 | import { start } from "ember-qunit"; 7 | 8 | setApplication(Application.create(config.APP)); 9 | 10 | setup(QUnit.assert); 11 | 12 | start(); 13 | -------------------------------------------------------------------------------- /client-app/tests/unit/controllers/index-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from "qunit"; 2 | import { setupTest } from "ember-qunit"; 3 | import MessageCollection from "client-app/models/message-collection"; 4 | import sinon from "sinon"; 5 | import * as utilities from "client-app/lib/utilities"; 6 | 7 | module("Unit | Controller | index", function (hooks) { 8 | setupTest(hooks); 9 | const ajaxStub = sinon.stub(utilities, "ajax"); 10 | 11 | test("uses search param to filter results", function (assert) { 12 | const controller = this.owner.lookup("controller:index"); 13 | const messages = MessageCollection.create(); 14 | const row1 = { message: "error tomtom", severity: 2, key: "ce1f53b0cc" }; 15 | const row2 = { message: "error steaky", severity: 3, key: "b083352825" }; 16 | 17 | messages.rows.addObjects([row1, row2]); 18 | controller.set("model", messages); 19 | 20 | assert.strictEqual(controller.searchTerm, null, "initial value is null"); 21 | assert.deepEqual(controller.model.rows, [row1, row2], "all rows"); 22 | 23 | ajaxStub.callsFake(async () => ({ 24 | search: "tomtom", 25 | filter: [5], 26 | messages: [], 27 | })); 28 | controller.set("search", "tomtom"); 29 | 30 | assert.strictEqual( 31 | controller.searchTerm, 32 | "tomtom", 33 | "search sets search term" 34 | ); 35 | assert.strictEqual( 36 | ajaxStub.firstCall.args[0], 37 | "/messages.json", 38 | "get messages" 39 | ); 40 | assert.deepEqual( 41 | ajaxStub.firstCall.args[1], 42 | { data: { filter: "5", search: "tomtom" }, method: "POST" }, 43 | "with correct terms" 44 | ); 45 | }); 46 | 47 | test("Creating inline grouping patterns finds the longest matching prefix between selected messages", function (assert) { 48 | const controller = this.owner.lookup("controller:index"); 49 | const messages = ["error foo tomtom", "error foo steaky", "error foo bar"]; 50 | 51 | assert.deepEqual( 52 | controller.findLongestMatchingPrefix(messages), 53 | "error foo " 54 | ); 55 | }); 56 | 57 | test("Creating inline grouping patterns can handle special characters", function (assert) { 58 | const controller = this.owner.lookup("controller:index"); 59 | let messages = [ 60 | "error foo!/@ tomtom", 61 | "error foo!/@ steaky", 62 | "error foo!/@ bar", 63 | ]; 64 | 65 | assert.deepEqual( 66 | controller.findLongestMatchingPrefix(messages), 67 | "error foo!/@ " 68 | ); 69 | 70 | messages = ["/$home/sam/.r\benv/", "/$home/sam/.r\benv/"]; 71 | 72 | assert.deepEqual( 73 | controller.findLongestMatchingPrefix(messages), 74 | "/$home/sam/.r\benv/" 75 | ); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /client-app/tests/unit/controllers/show-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from "qunit"; 2 | import { setupTest } from "ember-qunit"; 3 | 4 | module("Unit | Controller | show", function (hooks) { 5 | setupTest(hooks); 6 | 7 | // Replace this with your real tests. 8 | test("it exists", function (assert) { 9 | let controller = this.owner.lookup("controller:show"); 10 | assert.ok(controller); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /client-app/tests/unit/routes/index-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from "qunit"; 2 | import { setupTest } from "ember-qunit"; 3 | 4 | module("Unit | Route | index", function (hooks) { 5 | setupTest(hooks); 6 | 7 | test("it exists", function (assert) { 8 | let route = this.owner.lookup("route:index"); 9 | assert.ok(route); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /client-app/tests/unit/routes/show-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from "qunit"; 2 | import { setupTest } from "ember-qunit"; 3 | 4 | module("Unit | Route | show", function (hooks) { 5 | setupTest(hooks); 6 | 7 | test("it exists", function (assert) { 8 | let route = this.owner.lookup("route:show"); 9 | assert.ok(route); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /gemfiles/rails_6.1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | group :test do 6 | gem "rails", "~> 6.1.0" 7 | gem "concurrent-ruby", "1.3.4" 8 | gem "mutex_m" 9 | gem "bigdecimal" 10 | end 11 | 12 | gemspec path: "../" 13 | -------------------------------------------------------------------------------- /gemfiles/rails_7.0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | group :test do 6 | gem "rails", "~> 7.0.0" 7 | gem "concurrent-ruby", "1.3.4" 8 | gem "mutex_m" 9 | gem "bigdecimal" 10 | end 11 | 12 | gemspec path: "../" 13 | -------------------------------------------------------------------------------- /gemfiles/rails_7.1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | group :test do 6 | gem "rails", "~> 7.1.0" 7 | end 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /gemfiles/rails_7.2.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | group :test do 6 | gem "rails", "~> 7.2.0" 7 | end 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /lib/examples/sidekiq_logster_reporter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SidekiqLogsterReporter 4 | def call(ex, context = {}) 5 | # Pass context to Logster 6 | fake_env = {} 7 | context.each { |key, value| Logster.add_to_env(fake_env, key, value) } 8 | 9 | text = "Job exception: #{ex}\n" 10 | Logster.add_to_env(fake_env, :backtrace, ex.backtrace) if ex.backtrace 11 | 12 | Thread.current[Logster::Logger::LOGSTER_ENV] = fake_env 13 | Logster.logger.error(text) 14 | rescue => e 15 | Logster.logger.fatal( 16 | "Failed to log exception #{ex} #{hash}\nReason: #{e.class} #{e}\n#{e.backtrace.join("\n")}", 17 | ) 18 | ensure 19 | Thread.current[Logster::Logger::LOGSTER_ENV] = nil 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/logster.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logster/version" 4 | require "logster/logger" 5 | require "logster/message" 6 | require "logster/configuration" 7 | require "logster/web" 8 | require "logster/ignore_pattern" 9 | require "logster/pattern" 10 | require "logster/suppression_pattern" 11 | require "logster/grouping_pattern" 12 | require "logster/group" 13 | require "logster/cache" 14 | 15 | if defined?(Redis) 16 | require "logster/redis_store" 17 | else 18 | STDERR.puts "ERROR: Redis is not loaded, ensure redis gem is required before logster" 19 | exit 20 | end 21 | 22 | module Logster 23 | def self.logger=(logger) 24 | @logger = logger 25 | end 26 | 27 | def self.logger 28 | @logger 29 | end 30 | 31 | def self.store=(store) 32 | @store = store 33 | end 34 | 35 | def self.store 36 | @store 37 | end 38 | 39 | def self.config=(config) 40 | @config = config 41 | end 42 | 43 | def self.config 44 | @config ||= Configuration.new 45 | end 46 | 47 | def self.add_to_env(env, key, value) 48 | logster_env = Logster::Message.populate_from_env(env) 49 | logster_env[key] = value 50 | end 51 | 52 | def self.set_environments(envs) 53 | config.environments = envs 54 | end 55 | end 56 | 57 | # check logster/configuration.rb for config options 58 | # Logster.config.environments << :staging 59 | 60 | require "logster/rails/railtie" if defined?(::Rails::VERSION) && ::Rails::VERSION::MAJOR.to_i >= 3 61 | -------------------------------------------------------------------------------- /lib/logster/base_store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logster 4 | class BaseStore 5 | attr_accessor :level, :max_retention, :skip_empty, :ignore, :allow_custom_patterns 6 | 7 | def initialize 8 | @max_retention = 60 * 60 * 24 * 7 9 | @skip_empty = true 10 | @allow_custom_patterns = false 11 | @patterns_cache = Logster::Cache.new 12 | end 13 | 14 | # Save a new message at the front of the latest list. 15 | def save(message) 16 | not_implemented 17 | end 18 | 19 | # Modify the saved message to the given one (identified by message.key) and bump it to the top of the latest list 20 | def replace_and_bump(message) 21 | not_implemented 22 | end 23 | 24 | # Check if another message with the same grouping_key is already stored. 25 | # Returns the similar message's message.key 26 | def similar_key(message) 27 | not_implemented 28 | end 29 | 30 | # The number of messages currently stored. 31 | def count 32 | not_implemented 33 | end 34 | 35 | # Delete all unprotected messages in the store. 36 | def clear 37 | not_implemented 38 | end 39 | 40 | # Delete all messages, including protected messages. 41 | def clear_all 42 | not_implemented 43 | end 44 | 45 | # Get a message by its message_key 46 | def get(message_key, load_env: true) 47 | not_implemented 48 | end 49 | 50 | # Get a group of messages by their message_keys 51 | def bulk_get(message_keys) 52 | not_implemented 53 | end 54 | 55 | # Get all messages that you have in the store 56 | def get_all_messages 57 | not_implemented 58 | end 59 | 60 | # Get a message's env by its message_key 61 | def get_env(message_key) 62 | not_implemented 63 | end 64 | 65 | # Mark a message as protected; i.e. it is not deleted by the #clear method 66 | def protect(message_key) 67 | not_implemented 68 | end 69 | 70 | def delete(message_key) 71 | not_implemented 72 | end 73 | 74 | # Delete messages associated with given message_keys 75 | def bulk_delete(message_keys, grouping_keys) 76 | not_implemented 77 | end 78 | 79 | # Clear the protected mark for a message. 80 | def unprotect(message_key) 81 | not_implemented 82 | end 83 | 84 | # Solve a particular message, causing all old messages with matching version and backtrace 85 | # to be deleted (report should delete any solved messages when called) 86 | def solve(message_key) 87 | not_implemented 88 | end 89 | 90 | # Registers a rate limit on the given severities of logs 91 | def register_rate_limit(severities, limit, duration, &block) 92 | not_implemented 93 | end 94 | 95 | # Checks all the existing rate limiters to check if any has been exceeded 96 | def check_rate_limits(severity) 97 | not_implemented 98 | end 99 | 100 | # takes a string as `pattern` and places it under the set `set_name` 101 | def insert_pattern(set_name, pattern) 102 | not_implemented 103 | end 104 | 105 | # takes a string as `pattern` and removes it from the set `set_name` 106 | def remove_pattern(set_name, pattern) 107 | not_implemented 108 | end 109 | 110 | # returns an array of strings each of which must be convertible to regexp 111 | def get_patterns(set_name) 112 | not_implemented 113 | end 114 | 115 | # increments the number of messages that have been suppressed by a pattern 116 | def increment_ignore_count(pattern) 117 | not_implemented 118 | end 119 | 120 | # removes number of suppressed messages by a pattern 121 | def remove_ignore_count(pattern) 122 | not_implemented 123 | end 124 | 125 | # returns a hash that maps patterns to the number of messages they 126 | # have suppressed 127 | def get_all_ignore_count 128 | not_implemented 129 | end 130 | 131 | def rate_limited?(ip_address, perform: false, limit: 60) 132 | not_implemented 133 | end 134 | 135 | # find all pattern groups; returns an array of Logster::Group 136 | def find_pattern_groups(load_messages: true) 137 | not_implemented 138 | end 139 | 140 | # saves an instance of Logster::Group 141 | def save_pattern_group(group) 142 | not_implemented 143 | end 144 | 145 | # removes the Logster::Group instance associated with the given pattern 146 | def remove_pattern_group(pattern) 147 | not_implemented 148 | end 149 | 150 | def report(severity, progname, msg, opts = {}) 151 | return if (!msg || (String === msg && msg.empty?)) && skip_empty 152 | return if level && severity < level 153 | 154 | msg = msg.inspect unless String === msg 155 | msg = truncate_message(msg) 156 | message = Logster::Message.new(severity, progname, msg, opts[:timestamp], count: opts[:count]) 157 | 158 | env = opts[:env]&.dup || {} 159 | backtrace = opts[:backtrace] 160 | if Hash === env && env[:backtrace] 161 | # Special - passing backtrace through env 162 | backtrace = env.delete(:backtrace) 163 | end 164 | 165 | message.populate_from_env(env) 166 | 167 | if backtrace 168 | backtrace = backtrace.join("\n") if backtrace.respond_to? :join 169 | message.backtrace = backtrace 170 | else 171 | message.backtrace = caller.join("\n") 172 | end 173 | 174 | if ignore && 175 | ignore.any? { |pattern| 176 | if message =~ pattern 177 | val = Regexp === pattern ? pattern.inspect : pattern.to_s 178 | increment_ignore_count(val) 179 | true 180 | end 181 | } 182 | return 183 | end 184 | 185 | if Logster.config.enable_custom_patterns_via_ui || allow_custom_patterns 186 | custom_ignore = 187 | @patterns_cache.fetch(Logster::SuppressionPattern::CACHE_KEY) do 188 | Logster::SuppressionPattern.find_all(store: self) 189 | end 190 | if custom_ignore.any? { |pattern| 191 | if message =~ pattern 192 | increment_ignore_count(pattern.inspect) 193 | true 194 | end 195 | } 196 | return 197 | end 198 | end 199 | 200 | similar = nil 201 | 202 | if Logster.config.allow_grouping 203 | message.apply_message_size_limit( 204 | Logster.config.maximum_message_size_bytes, 205 | gems_dir: Logster.config.gems_dir, 206 | ) 207 | key = self.similar_key(message) 208 | similar = get(key, load_env: false) if key 209 | end 210 | 211 | message.drop_redundant_envs(Logster.config.max_env_count_per_message) 212 | message.apply_env_size_limit(Logster.config.max_env_bytes) 213 | saved = true 214 | if similar 215 | similar.merge_similar_message(message) 216 | replace_and_bump(similar) 217 | similar 218 | else 219 | message.apply_message_size_limit( 220 | Logster.config.maximum_message_size_bytes, 221 | gems_dir: Logster.config.gems_dir, 222 | ) 223 | saved = save(message) 224 | message 225 | end 226 | 227 | message = similar || message 228 | 229 | if (Logster.config.enable_custom_patterns_via_ui || allow_custom_patterns) && saved 230 | grouping_patterns = 231 | @patterns_cache.fetch(Logster::GroupingPattern::CACHE_KEY) do 232 | Logster::GroupingPattern.find_all(store: self) 233 | end 234 | 235 | grouping_patterns.each do |pattern| 236 | if message =~ pattern 237 | group = find_pattern_groups() { |pat| pat == pattern }[0] 238 | group ||= Logster::Group.new(pattern.inspect) 239 | group.add_message(message) 240 | save_pattern_group(group) if group.changed? 241 | break 242 | end 243 | end 244 | end 245 | message 246 | end 247 | 248 | def clear_patterns_cache(key) 249 | @patterns_cache.clear(key) 250 | end 251 | 252 | private 253 | 254 | def truncate_message(msg) 255 | cap = Logster.config.maximum_message_length 256 | msg.size <= cap ? msg : msg[0...cap] + "..." 257 | end 258 | 259 | def not_implemented 260 | raise "Not Implemented" 261 | end 262 | end 263 | end 264 | -------------------------------------------------------------------------------- /lib/logster/cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logster 4 | class Cache 5 | def initialize(age = 2) 6 | @age = age 7 | @hash = {} 8 | end 9 | 10 | def fetch(key) 11 | if !@hash.key?(key) || 12 | @hash[key][:created_at] + @age < Process.clock_gettime(Process::CLOCK_MONOTONIC) 13 | @hash[key] = { data: yield, created_at: Process.clock_gettime(Process::CLOCK_MONOTONIC) } 14 | end 15 | @hash[key][:data] 16 | end 17 | 18 | def clear(key) 19 | @hash.delete(key) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/logster/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logster 4 | class Configuration 5 | attr_accessor( 6 | :allow_grouping, 7 | :application_version, 8 | :current_context, 9 | :env_expandable_keys, 10 | :enable_custom_patterns_via_ui, 11 | :enable_js_error_reporting, 12 | :environments, 13 | :rate_limit_error_reporting, 14 | :web_title, 15 | :maximum_message_size_bytes, 16 | :project_directories, 17 | :enable_backtrace_links, 18 | :gems_dir, 19 | :max_env_bytes, 20 | :max_env_count_per_message, 21 | :maximum_message_length, 22 | :use_full_hostname, 23 | :back_to_site_link_text, 24 | :back_to_site_link_path, 25 | ) 26 | 27 | attr_writer :subdirectory 28 | 29 | def initialize 30 | # lambda |env,block| 31 | @current_context = lambda { |_, &block| block.call } 32 | @environments = %i[development production] 33 | @subdirectory = nil 34 | @env_expandable_keys = [] 35 | @enable_custom_patterns_via_ui = false 36 | @rate_limit_error_reporting = true 37 | @enable_js_error_reporting = true 38 | @maximum_message_size_bytes = 10_000 39 | @max_env_bytes = 1000 40 | @max_env_count_per_message = 50 41 | @project_directories = [] 42 | @enable_backtrace_links = true 43 | @gems_dir = Gem.dir + "/gems/" 44 | @maximum_message_length = 2000 45 | @use_full_hostname = nil 46 | 47 | @allow_grouping = false 48 | 49 | @allow_grouping = true if defined?(::Rails.env) && ::Rails.env.production? 50 | end 51 | 52 | def subdirectory 53 | @subdirectory || "/logs" 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/logster/defer_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logster/scheduler" 4 | 5 | module Logster 6 | class DeferLogger < ::Logster::Logger 7 | private 8 | 9 | def report_to_store(severity, progname, message, opts = {}) 10 | opts[:backtrace] ||= caller.join("\n") 11 | Logster::Scheduler.schedule { super(severity, progname, message, opts) } 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/logster/group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logster 4 | class Group 5 | MAX_SIZE = 100 6 | 7 | attr_reader :key, :messages_keys, :timestamp, :messages 8 | attr_accessor :changed, :pattern, :count 9 | 10 | def initialize(key, messages_keys = [], timestamp: 0, count: 0) 11 | @key = key 12 | @messages_keys = messages_keys || [] 13 | @timestamp = timestamp 14 | @count = count 15 | @changed = true 16 | end 17 | 18 | def self.from_json(json) 19 | hash = JSON.parse(json) 20 | group = 21 | new( 22 | hash["key"], 23 | hash["messages_keys"], 24 | timestamp: hash["timestamp"] || 0, 25 | count: hash["count"] || 0, 26 | ) 27 | group.changed = false 28 | group 29 | end 30 | 31 | def self.max_size 32 | (defined?(@max_size) && @max_size) || MAX_SIZE 33 | end 34 | 35 | def to_h 36 | { key: @key, messages_keys: @messages_keys, timestamp: @timestamp, count: @count } 37 | end 38 | 39 | def to_h_web 40 | { 41 | regex: @key, 42 | count: @count, 43 | timestamp: @timestamp, 44 | messages: @messages, 45 | severity: -1, 46 | group: true, 47 | } 48 | end 49 | 50 | def to_json(opts = nil) 51 | JSON.fast_generate(self.to_h, opts) 52 | end 53 | 54 | def add_message(message) 55 | if !@messages_keys.include?(message.key) 56 | @messages_keys.unshift(message.key) 57 | @count += 1 58 | @changed = true 59 | end 60 | if @timestamp < message.timestamp 61 | @timestamp = message.timestamp 62 | @messages_keys.unshift(@messages_keys.slice!(@messages_keys.index(message.key))) 63 | @changed = true 64 | end 65 | if self.count > max_size 66 | @messages_keys.slice!(max_size..-1) 67 | @changed = true 68 | end 69 | end 70 | 71 | def remove_message(message) 72 | index = @messages_keys.index(message.key) 73 | if index 74 | @messages_keys.slice!(index) 75 | @changed = true 76 | end 77 | end 78 | 79 | def messages=(messages) 80 | messages.compact! 81 | messages.uniq!(&:key) 82 | if messages.size > 0 83 | messages.sort_by!(&:timestamp) 84 | messages.reverse! 85 | messages.slice!(max_size..-1) if messages.size > max_size 86 | @messages = messages 87 | before = @messages_keys.sort 88 | @messages_keys = @messages.map(&:key) 89 | @timestamp = @messages[0].timestamp 90 | @changed = before != @messages_keys.sort 91 | else 92 | @messages_keys = [] 93 | @messages = [] 94 | @timestamp = 0 95 | @changed = true 96 | end 97 | end 98 | 99 | def changed? 100 | @changed 101 | end 102 | 103 | private 104 | 105 | def max_size 106 | self.class.max_size 107 | end 108 | 109 | GroupWeb = 110 | Struct.new(*%i[regex count timestamp messages row_id]) do 111 | def to_json(opts = nil) 112 | JSON.fast_generate(self.to_h.merge(severity: -1, group: true), opts) 113 | end 114 | 115 | def key 116 | self.regex # alias for testing convenience 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/logster/grouping_pattern.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logster 4 | class GroupingPattern < Pattern 5 | CACHE_KEY = :grouping 6 | def self.set_name 7 | "__LOGSTER__grouping_patterns_set".freeze 8 | end 9 | 10 | def save(args = {}) 11 | super 12 | existing_groups = @store.find_pattern_groups 13 | group = Logster::Group.new(self.to_s) 14 | messages = @store.get_all_messages(with_env: false) 15 | messages.select! do |m| 16 | m.message =~ self.pattern && existing_groups.none? { |g| g.messages_keys.include?(m.key) } 17 | end 18 | group.messages = messages 19 | group.count = messages.size 20 | @store.save_pattern_group(group) if group.changed? 21 | @store.clear_patterns_cache(CACHE_KEY) 22 | end 23 | 24 | def destroy(clear_cache: true) # arg used in tests 25 | super() 26 | @store.remove_pattern_group(self.pattern) 27 | @store.clear_patterns_cache(CACHE_KEY) if clear_cache 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/logster/ignore_pattern.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logster 4 | class IgnorePattern 5 | def initialize(message_pattern = nil, env_patterns = nil) 6 | @msg_match = message_pattern 7 | @env_match = env_patterns 8 | end 9 | 10 | def self.from_message_and_request_uri(msg, request) 11 | IgnorePattern.new(msg, REQUEST_URI: request) 12 | end 13 | 14 | def matches?(message) 15 | if @msg_match 16 | return false unless compare(message.message, @msg_match) 17 | end 18 | 19 | if @env_match 20 | return false unless compare(message.env, @env_match) 21 | end 22 | 23 | true 24 | end 25 | 26 | def to_s 27 | "<#Logster::IgnorePattern, msg_match: #{@msg_match.inspect}, env_match: #{@env_match.inspect}>" 28 | end 29 | 30 | private 31 | 32 | def compare(message, pattern) 33 | return false unless message && pattern 34 | 35 | case pattern 36 | when Regexp 37 | message.to_s =~ pattern 38 | when String 39 | message.to_s =~ Regexp.new(Regexp.escape(pattern), Regexp::IGNORECASE) 40 | when Hash 41 | if Hash === message 42 | compare_hash(message, pattern) 43 | else 44 | false 45 | end 46 | else 47 | false 48 | end 49 | end 50 | 51 | def compare_hash(message_hash, pattern_hash) 52 | return false unless message_hash 53 | pattern_hash.each do |key, value| 54 | return false unless compare(get_indifferent(message_hash, key), value) 55 | end 56 | true 57 | end 58 | 59 | def get_indifferent(hash, key) 60 | return hash[key] if hash[key] 61 | return hash[key.to_s] if hash[key.to_s] 62 | # no key.to_sym please, memory leak in Ruby < 2.2 63 | nil 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/logster/logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logger" 4 | 5 | module Logster 6 | class Logger < ::Logger 7 | LOGSTER_ENV = "logster_env" 8 | 9 | attr_accessor :store, :skip_store 10 | attr_reader :chained 11 | 12 | def initialize(store) 13 | super(nil) 14 | @store = store 15 | @chained = [] 16 | @subscribers = [] 17 | @skip_store = false 18 | @logster_override_level_key = "logster_override_level_#{object_id}" 19 | end 20 | 21 | def override_level=(val) 22 | Thread.current[@logster_override_level_key] = val 23 | end 24 | 25 | def override_level 26 | Thread.current[@logster_override_level_key] 27 | end 28 | 29 | def chain(logger) 30 | @chained << logger 31 | end 32 | 33 | ## 34 | # Subscribe to log events. 35 | # 36 | # Example: 37 | # logger.subscribe do |severity, message, progname, opts, &block| 38 | # YourCustomLogger.log(severity, message, progname, opts, &block) 39 | # end 40 | def subscribe(&block) 41 | @subscribers << block 42 | end 43 | 44 | def add_to_chained(logger, severity, message, progname, opts = nil, &block) 45 | if logger.respond_to? :skip_store 46 | old = logger.skip_store 47 | logger.skip_store = @skip_store 48 | end 49 | 50 | if logger.is_a?(Logster::Logger) 51 | logger.add(severity, message, progname, opts, &block) 52 | else 53 | logger.add(severity, message, progname, &block) 54 | end 55 | ensure 56 | logger.skip_store = old if logger.respond_to? :skip_store 57 | end 58 | 59 | def add(*args, &block) 60 | add_with_opts(*args, &block) 61 | end 62 | 63 | def level 64 | Thread.current[@logster_override_level_key] || @level 65 | end 66 | 67 | def add_with_opts(severity, message = nil, progname = progname(), opts = nil, &block) 68 | return true if severity < level 69 | 70 | # it is not fun losing messages cause encoding is bad 71 | # protect all messages by scrubbing if needed 72 | message = message.scrub if message && !message.valid_encoding? 73 | 74 | # we want to get the backtrace as early as possible so that logster's 75 | # own methods don't show up as the first few frames in the backtrace 76 | if !opts || !opts.key?(:backtrace) 77 | opts ||= {} 78 | backtrace = message.backtrace if message.kind_of?(::Exception) 79 | backtrace ||= progname.backtrace if progname.kind_of?(::Exception) 80 | if !backtrace 81 | backtrace = caller_locations 82 | backtrace.shift while backtrace.first.path.end_with?("/logger.rb") 83 | end 84 | backtrace = backtrace.join("\n") 85 | opts[:backtrace] = backtrace 86 | end 87 | 88 | notify_subscribers(severity, message, progname, opts, &block) 89 | add_to_chained_loggers(severity, message, progname, opts, &block) 90 | 91 | return if @skip_store 92 | 93 | progname ||= @progname 94 | if message.nil? 95 | if block_given? 96 | message = yield 97 | else 98 | message = progname 99 | progname = @progname 100 | end 101 | end 102 | 103 | message = formatter.call(severity, Time.now, progname, message) if formatter 104 | 105 | opts ||= {} 106 | opts[:env] ||= Thread.current[LOGSTER_ENV] 107 | 108 | report_to_store(severity, progname, message, opts) 109 | rescue => e 110 | # don't blow up if STDERR is somehow closed 111 | begin 112 | STDERR.puts "Failed to report error: #{e} #{severity} #{message} #{progname}" 113 | rescue StandardError 114 | nil 115 | end 116 | end 117 | 118 | private 119 | 120 | def add_to_chained_loggers(severity, message, progname, opts, &block) 121 | chained_length = @chained.length 122 | 123 | if chained_length > 0 124 | i = 0 125 | # micro optimise for logging since while loop is almost twice as fast 126 | while i < chained_length 127 | begin 128 | add_to_chained(@chained[i], severity, message, progname, opts, &block) 129 | rescue => e 130 | # don't blow up if STDERR is somehow closed 131 | begin 132 | STDERR.puts "Failed to report message to chained logger: #{e.class} (#{e.message})\n#{e.backtrace.join("\n")}" 133 | rescue StandardError 134 | nil 135 | end 136 | end 137 | i += 1 138 | end 139 | end 140 | end 141 | 142 | def notify_subscribers(severity, message, progname, opts, &block) 143 | subscribers_length = @subscribers.length 144 | 145 | if subscribers_length > 0 146 | i = 0 147 | 148 | # micro optimise for logging since while loop is almost twice as fast 149 | while i < subscribers_length 150 | begin 151 | @subscribers[i].call(severity, message, progname, opts, &block) 152 | rescue => e 153 | # don't blow up if STDERR is somehow closed 154 | begin 155 | STDERR.puts "Failed to report message to subscriber: #{e.class} (#{e.message})\n#{e.backtrace.join("\n")}" 156 | rescue StandardError 157 | nil 158 | end 159 | end 160 | 161 | i += 1 162 | end 163 | end 164 | end 165 | 166 | def report_to_store(severity, progname, message, opts = {}) 167 | @store.report(severity, progname, message, opts) 168 | end 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /lib/logster/middleware/debug_exceptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Logster::Middleware::DebugExceptions < ActionDispatch::DebugExceptions 4 | private 5 | 6 | def log_error(request_or_env, wrapper) 7 | env = 8 | if Rails::VERSION::MAJOR > 4 9 | request_or_env.env 10 | else 11 | request_or_env 12 | end 13 | 14 | exception = wrapper.exception 15 | 16 | Logster 17 | .config 18 | .current_context 19 | .call(env) do 20 | Logster.logger.add_with_opts( 21 | ::Logger::Severity::FATAL, 22 | "#{exception.class} (#{exception})\n#{wrapper.application_trace.join("\n")}", 23 | "web-exception", 24 | backtrace: wrapper.full_trace.join("\n"), 25 | env: env, 26 | ) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/logster/middleware/reporter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logster 4 | module Middleware 5 | class Reporter 6 | PATH_INFO = "PATH_INFO" 7 | SCRIPT_NAME = "SCRIPT_NAME" 8 | 9 | def initialize(app, config = {}) 10 | @app = app 11 | @error_path = Logster.config.subdirectory + "/report_js_error" 12 | end 13 | 14 | def call(env) 15 | Thread.current[Logster::Logger::LOGSTER_ENV] = env 16 | 17 | path = env[PATH_INFO] 18 | script_name = env[SCRIPT_NAME] 19 | 20 | path = script_name + path if script_name && script_name.length > 0 21 | 22 | if path == @error_path 23 | return 403, {}, ["Access Denied"] if !Logster.config.enable_js_error_reporting 24 | 25 | Logster 26 | .config 27 | .current_context 28 | .call(env) do 29 | if Logster.config.rate_limit_error_reporting 30 | req = Rack::Request.new(env) 31 | if Logster.store.rate_limited?(req.ip, perform: true) 32 | return 429, {}, ["Rate Limited"] 33 | end 34 | end 35 | report_js_error(env) 36 | end 37 | return 200, {}, ["OK"] 38 | end 39 | 40 | @app.call(env) 41 | ensure 42 | Thread.current[Logster::Logger::LOGSTER_ENV] = nil 43 | end 44 | 45 | def report_js_error(env) 46 | req = Rack::Request.new(env) 47 | 48 | params = req.params 49 | 50 | message = (params["message"] || "").dup 51 | message << "\nUrl: " << params["url"] if params["url"] 52 | message << "\nLine: " << params["line"] if params["line"] 53 | message << "\nColumn: " << params["column"] if params["column"] 54 | message << "\nWindow Location: " << params["window_location"] if params["window_location"] 55 | 56 | backtrace = params["stacktrace"] || "" 57 | 58 | severity = ::Logger::Severity::WARN 59 | if params["severity"] && ::Logger::Severity.const_defined?(params["severity"].upcase) 60 | severity = ::Logger::Severity.const_get(params["severity"].upcase) 61 | end 62 | 63 | Logster.store.report(severity, "javascript", message, backtrace: backtrace, env: env) 64 | 65 | true 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/logster/pattern.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logster 4 | class Pattern 5 | @child_classes = [] 6 | 7 | class PatternError < StandardError 8 | end 9 | 10 | def self.inherited(subclass) 11 | @child_classes << subclass 12 | super 13 | end 14 | 15 | def self.child_classes 16 | @child_classes 17 | end 18 | 19 | def self.set_name 20 | raise "Please override the `set_name` method and specify and a name for this set" 21 | end 22 | 23 | def self.parse_pattern(string) 24 | return string if Regexp === string 25 | return unless String === string 26 | if string[0] == "/" 27 | return unless string =~ %r{/(.+)/(.*)} 28 | string = $1 29 | flag = Regexp::IGNORECASE if $2 && $2.include?("i") 30 | end 31 | Regexp.new(string, flag) 32 | rescue RegexpError 33 | nil 34 | end 35 | 36 | def self.find_all(raw: false, store: Logster.store) 37 | patterns = store.get_patterns(set_name) || [] 38 | patterns.map! { |p| parse_pattern(p) } unless raw 39 | patterns.compact! 40 | patterns 41 | end 42 | 43 | def self.find(pattern, store: Logster.store) 44 | pattern = parse_pattern(pattern).inspect 45 | return nil unless pattern 46 | pattern = find_all(raw: true, store: store).find { |p| p == pattern } 47 | return nil unless pattern 48 | new(pattern) 49 | end 50 | 51 | def self.valid?(pattern) 52 | return false unless Regexp === pattern 53 | pattern_size = pattern.inspect.size 54 | pattern_size > 3 && pattern_size < 500 55 | end 56 | 57 | def initialize(pattern, store: Logster.store) 58 | self.pattern = pattern 59 | @store = store 60 | end 61 | 62 | def valid? 63 | self.class.valid?(pattern) 64 | end 65 | 66 | def to_s 67 | pattern.inspect 68 | end 69 | 70 | def save(args = {}) 71 | ensure_valid! 72 | @store.insert_pattern(set_name, self.to_s) 73 | end 74 | 75 | def modify(new_pattern) 76 | new_pattern = self.class.parse_pattern(new_pattern) 77 | raise PatternError.new unless self.class.valid?(new_pattern) 78 | destroy 79 | self.pattern = new_pattern 80 | save 81 | end 82 | 83 | def destroy 84 | @store.remove_pattern(set_name, self.to_s) 85 | end 86 | 87 | def pattern 88 | @pattern 89 | end 90 | 91 | private 92 | 93 | def pattern=(new_pattern) 94 | @pattern = self.class.parse_pattern(new_pattern) 95 | end 96 | 97 | def set_name 98 | self.class.set_name 99 | end 100 | 101 | def ensure_valid! 102 | raise PatternError.new("Invalid pattern") unless valid? 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/logster/rails/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logster::Rails 4 | # this magically registers logster.js in the asset pipeline 5 | class Engine < Rails::Engine 6 | end 7 | 8 | class << self 9 | def set_logger(config) 10 | return if !Logster.config.environments.include?(Rails.env.to_sym) 11 | 12 | require "logster/middleware/debug_exceptions" 13 | require "logster/middleware/reporter" 14 | 15 | store = Logster.store ||= Logster::RedisStore.new 16 | store.level = Logger::Severity::WARN if Rails.env.production? 17 | 18 | if Rails.env.development? 19 | require "logster/defer_logger" 20 | logger = Logster::DeferLogger.new(store) 21 | else 22 | logger = Logster::Logger.new(store) 23 | end 24 | 25 | logger.level = ::Rails.logger.level 26 | 27 | Logster.logger = config.logger = logger 28 | 29 | if rails_71? 30 | ::Rails.logger.broadcast_to(logger) 31 | else 32 | logger.chain(::Rails.logger) 33 | ::Rails.logger = logger 34 | end 35 | end 36 | 37 | def initialize!(app) 38 | return if !Logster.config.environments.include?(Rails.env.to_sym) 39 | return unless logster_enabled? 40 | 41 | if Logster.config.enable_js_error_reporting 42 | app.middleware.insert_before ActionDispatch::ShowExceptions, Logster::Middleware::Reporter 43 | end 44 | 45 | if Rails::VERSION::MAJOR == 3 46 | app.middleware.insert_before ActionDispatch::DebugExceptions, 47 | Logster::Middleware::DebugExceptions 48 | else 49 | app.middleware.insert_before ActionDispatch::DebugExceptions, 50 | Logster::Middleware::DebugExceptions, 51 | Rails.application 52 | end 53 | 54 | app.middleware.delete ActionDispatch::DebugExceptions 55 | app.config.colorize_logging = false 56 | 57 | unless Logster.config.application_version 58 | git_version = `cd #{Rails.root} && git rev-parse --short HEAD 2> /dev/null` 59 | Logster.config.application_version = git_version.strip if git_version.present? 60 | end 61 | end 62 | 63 | private 64 | 65 | def logster_enabled? 66 | return ::Rails.logger == Logster.logger unless rails_71? 67 | ::Rails.logger.broadcasts.include?(Logster.logger) 68 | end 69 | 70 | def rails_71? 71 | ::Rails.version >= "7.1" 72 | end 73 | end 74 | 75 | class Railtie < ::Rails::Railtie 76 | config.before_initialize { Logster::Rails.set_logger(config) } 77 | 78 | initializer "logster.configure_rails_initialization" do |app| 79 | Logster::Rails.initialize!(app) 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/logster/redis_rate_limiter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logster 4 | class RedisRateLimiter 5 | BUCKETS = 6 6 | PREFIX = "__LOGSTER__RATE_LIMIT".freeze 7 | 8 | attr_reader :duration, :callback 9 | 10 | def self.clear_all(redis, redis_prefix = nil) 11 | prefix = key_prefix(redis_prefix) 12 | 13 | redis.eval " 14 | local keys = redis.call('keys', '*#{prefix}*') 15 | if (table.getn(keys) > 0) then 16 | redis.call('del', unpack(keys)) 17 | end 18 | " 19 | end 20 | 21 | def initialize(redis, severities, limit, duration, redis_prefix = nil, callback = nil) 22 | @severities = severities 23 | @limit = limit 24 | @duration = duration 25 | @callback = callback 26 | @redis_prefix = redis_prefix 27 | @redis = redis 28 | @bucket_range = @duration / BUCKETS 29 | @mget_keys = (0..(BUCKETS - 1)).map { |i| "#{key}:#{i}" } 30 | end 31 | 32 | def retrieve_rate 33 | @redis.mget(@mget_keys).reduce(0) { |sum, value| sum + value.to_i } 34 | end 35 | 36 | def check(severity) 37 | return if !@severities.include?(severity) 38 | time = Time.now.to_i 39 | num = bucket_number(time) 40 | redis_key = "#{key}:#{num}" 41 | 42 | current_rate = @redis.eval <<-LUA 43 | local bucket_number = #{num} 44 | local bucket_count = redis.call("INCR", "#{redis_key}") 45 | 46 | if bucket_count == 1 then 47 | redis.call("EXPIRE", "#{redis_key}", "#{bucket_expiry(time)}") 48 | redis.call("DEL", "#{callback_key}") 49 | end 50 | 51 | local function retrieve_rate () 52 | local sum = 0 53 | local values = redis.call("MGET", #{mget_keys(num)}) 54 | for index, value in ipairs(values) do 55 | if value ~= false then sum = sum + value end 56 | end 57 | return sum 58 | end 59 | 60 | return (retrieve_rate() + bucket_count) 61 | LUA 62 | 63 | if !@redis.get(callback_key) && (current_rate >= @limit) 64 | @callback.call(current_rate) if @callback 65 | @redis.set(callback_key, 1) 66 | end 67 | 68 | current_rate 69 | end 70 | 71 | def key 72 | # "_LOGSTER_RATE_LIMIT:012:20:30" 73 | # Triggers callback when log levels of :debug, :info and :warn occurs 20 times within 30 secs 74 | "#{key_prefix}:#{@severities.join("")}:#{@limit}:#{@duration}" 75 | end 76 | 77 | def callback_key 78 | "#{key}:callback_triggered" 79 | end 80 | 81 | private 82 | 83 | def self.key_prefix(redis_prefix) 84 | redis_prefix ? "#{redis_prefix.call}:#{PREFIX}" : PREFIX 85 | end 86 | 87 | def key_prefix 88 | self.class.key_prefix(@redis_prefix) 89 | end 90 | 91 | def mget_keys(bucket_num) 92 | keys = @mget_keys.dup 93 | keys.delete_at(bucket_num) 94 | keys.map { |key| "'#{key}'" }.join(", ") 95 | end 96 | 97 | def bucket_number(time) 98 | (time % @duration) / @bucket_range 99 | end 100 | 101 | def bucket_expiry(time) 102 | @duration - ((time % @duration) % @bucket_range) 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/logster/scheduler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logster 4 | module Deferer 5 | attr_reader :queue, :thread 6 | def initialize 7 | @queue = Queue.new 8 | @mutex = Mutex.new 9 | @thread = nil 10 | @enabled = true 11 | end 12 | 13 | def disable 14 | @enabled = false 15 | end 16 | 17 | def enable 18 | @enabled = true 19 | end 20 | 21 | def schedule(&blk) 22 | if @enabled 23 | start_thread if !@thread&.alive? 24 | @queue << blk 25 | else 26 | return if blk == :terminate 27 | blk.call 28 | end 29 | end 30 | 31 | private 32 | 33 | def start_thread 34 | @mutex.synchronize { @thread = Thread.new { do_work } if !@thread&.alive? } 35 | end 36 | 37 | def do_work 38 | while true 39 | blk = @queue.pop 40 | # we need to be able to break the loop so that the new 41 | # thread "finishes" and let us test this code. 42 | break if blk == :terminate 43 | blk.call 44 | end 45 | end 46 | end 47 | 48 | class Scheduler 49 | extend Deferer 50 | initialize 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/logster/suppression_pattern.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logster 4 | class SuppressionPattern < Pattern 5 | CACHE_KEY = :suppression 6 | def self.set_name 7 | "__LOGSTER__suppression_patterns_set".freeze 8 | end 9 | 10 | def save(args = {}) 11 | super 12 | @store.clear_patterns_cache(CACHE_KEY) 13 | retro_delete_messages if args[:retroactive] 14 | end 15 | 16 | def destroy(clear_cache: true) # arg used in tests 17 | super() 18 | @store.remove_ignore_count(self.to_s) 19 | @store.clear_patterns_cache(CACHE_KEY) if clear_cache 20 | end 21 | 22 | private 23 | 24 | def retro_delete_messages 25 | keys = [] 26 | grouping_keys = [] 27 | @store 28 | .get_all_messages(with_env: false) 29 | .each do |message| 30 | if message =~ self.pattern 31 | keys << message.key 32 | grouping_keys << message.grouping_key 33 | end 34 | end 35 | @store.bulk_delete(keys, grouping_keys) if keys.size > 0 && grouping_keys.size > 0 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/logster/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logster 4 | VERSION = "2.20.1" 5 | end 6 | -------------------------------------------------------------------------------- /lib/logster/web.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logster/middleware/viewer" 4 | 5 | class Logster::Web 6 | class FourOhFour 7 | def call(env) 8 | [404, {}, ["not found"]] 9 | end 10 | end 11 | 12 | def self.call(env) 13 | @middleware ||= Logster::Middleware::Viewer.new(FourOhFour.new) 14 | @middleware.call(env) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /logster.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # frozen_string_literal: true 3 | 4 | lib = File.expand_path("../lib", __FILE__) 5 | $LOAD_PATH.unshift(lib) if !$LOAD_PATH.include?(lib) 6 | require "logster/version" 7 | 8 | Gem::Specification.new do |spec| 9 | spec.name = "logster" 10 | spec.version = Logster::VERSION 11 | spec.authors = ["Sam Saffron"] 12 | spec.email = ["sam.saffron@gmail.com"] 13 | spec.summary = "UI for viewing logs in Rack" 14 | spec.description = "UI for viewing logs in Rack" 15 | spec.homepage = "https://github.com/discourse/logster" 16 | spec.license = "MIT" 17 | 18 | spec.required_ruby_version = ">= 2.5.0" 19 | 20 | files = `git ls-files -z`.split("\x0").reject { |f| f.start_with?(/website|bin/) } 21 | files += Dir.glob("assets/javascript/*") 22 | files += Dir.glob("assets/stylesheets/*") 23 | spec.files = files 24 | 25 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 26 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 27 | spec.require_paths = ["lib"] 28 | 29 | # NOTE dependency on rack is not explicit, this enables us to use 30 | # logster outside of rack (for reporting) 31 | 32 | spec.add_development_dependency "bundler" 33 | spec.add_development_dependency "rake" 34 | spec.add_development_dependency "rack" 35 | spec.add_development_dependency "redis" 36 | spec.add_development_dependency "guard" 37 | spec.add_development_dependency "guard-minitest" 38 | spec.add_development_dependency "timecop" 39 | spec.add_development_dependency "byebug", "~> 11.1.0" 40 | spec.add_development_dependency "rubocop-discourse" 41 | spec.add_development_dependency "syntax_tree" 42 | spec.add_development_dependency "sqlite3" 43 | end 44 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "boot" 3 | 4 | require "active_record/railtie" 5 | require "action_controller/railtie" 6 | 7 | # Require the gems listed in Gemfile, including any gems 8 | # you've limited to :test, :development, or :production. 9 | Bundler.require(*Rails.groups) 10 | 11 | require "logster" 12 | 13 | module Dummy 14 | class Application < Rails::Application 15 | config.load_defaults Rails::VERSION::STRING.to_f 16 | 17 | # For compatibility with applications that use this config 18 | config.action_controller.include_all_helpers = false 19 | 20 | config.eager_load = false 21 | 22 | # Configuration for the application, engines, and railties goes here. 23 | # 24 | # These settings can be overridden in specific environments using the files 25 | # in config/environments, which are processed later. 26 | # 27 | # config.time_zone = "Central Time (US & Canada)" 28 | # config.eager_load_paths << Rails.root.join("extras") 29 | Logster.set_environments([Rails.env.to_sym]) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Set up gems listed in the Gemfile. 3 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 4 | 5 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 6 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 7 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Load the Rails application. 3 | require_relative "application" 4 | 5 | # Initialize the Rails application. 6 | Rails.application.initialize! 7 | -------------------------------------------------------------------------------- /test/examples/test_sidekiq_reporter_example.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | require "logster/logger" 5 | require "logster/redis_store" 6 | require "logger" 7 | require "examples/sidekiq_logster_reporter" 8 | 9 | class TestSidekiqReporter < MiniTest::Test 10 | def setup 11 | Logster.store = @store = Logster::RedisStore.new(Redis.new) 12 | Logster.logger = @logger = Logster::Logger.new(Logster.store) 13 | @store.clear_all 14 | end 15 | 16 | def teardown 17 | @store.clear_all 18 | end 19 | 20 | def test_sidekiq_handler_example 21 | handler = SidekiqLogsterReporter.new 22 | error = nil 23 | begin 24 | raise TypeError.new 25 | rescue => e 26 | error = e 27 | end 28 | trace = error.backtrace 29 | 30 | handler.call(error, code: "Test", something_important: "Foo", params: { article_id: 20 }) 31 | 32 | report = @store.latest[0] 33 | 34 | # Message is right format 35 | assert_equal("Job exception: TypeError\n", report.message) 36 | 37 | # A backtrace is joined() 38 | assert_equal(trace.join("\n"), report.backtrace) 39 | # The backtrace is deleted from the env 40 | assert_nil(report.env["backtrace"]) 41 | assert_nil(report.env[:backtrace]) 42 | 43 | # The env is in the report 44 | assert_equal("Test", report.env["code"]) 45 | assert_equal(20, report.env["params"]["article_id"]) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/fake_data/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'redis' 6 | gem 'logster', path: '../../' 7 | -------------------------------------------------------------------------------- /test/fake_data/generate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "redis" 4 | require "logster" 5 | 6 | Logster.config.allow_grouping = true 7 | Logster.config.application_version = "ABC123" 8 | Logster.store = Logster::RedisStore.new 9 | 10 | 10.times do 11 | Logster.store.report( 12 | Logger::WARN, 13 | "application", 14 | "test warning", 15 | backtrace: "method1\nmethod2", 16 | env: { 17 | something: ["hello world", "hello places"], 18 | another: { 19 | thing: "something else", 20 | }, 21 | }, 22 | ) 23 | end 24 | -------------------------------------------------------------------------------- /test/logster/middleware/test_reporter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../test_helper" 4 | require "rack" 5 | require "logster/redis_store" 6 | require "logster/middleware/reporter" 7 | 8 | class TestReporter < Minitest::Test 9 | def setup 10 | Logster.store = Logster::RedisStore.new 11 | Logster.store.clear_all 12 | Logster.config.enable_js_error_reporting = true 13 | Logster.config.rate_limit_error_reporting = true 14 | end 15 | 16 | def test_logs_errors 17 | reporter = Logster::Middleware::Reporter.new(nil) 18 | env = Rack::MockRequest.env_for("/logs/report_js_error?message=hello") 19 | status, = reporter.call(env) 20 | 21 | assert_equal(200, status) 22 | assert_equal(1, Logster.store.count) 23 | end 24 | 25 | def test_logs_severity_of_errors 26 | Logster.config.rate_limit_error_reporting = false 27 | 28 | reporter = Logster::Middleware::Reporter.new(nil) 29 | env = Rack::MockRequest.env_for("/logs/report_js_error?message=hello") 30 | reporter.call(env) 31 | 32 | assert_equal(Logger::Severity::WARN, Logster.store.latest[-1].severity) 33 | 34 | reporter = Logster::Middleware::Reporter.new(nil) 35 | env = Rack::MockRequest.env_for("/logs/report_js_error?message=hello&severity=invalid") 36 | reporter.call(env) 37 | 38 | assert_equal(Logger::Severity::WARN, Logster.store.latest[-1].severity) 39 | 40 | reporter = Logster::Middleware::Reporter.new(nil) 41 | env = Rack::MockRequest.env_for("/logs/report_js_error?message=hello&severity=error") 42 | reporter.call(env) 43 | 44 | assert_equal(Logger::Severity::ERROR, Logster.store.latest[-1].severity) 45 | end 46 | 47 | def test_respects_ban_on_errors 48 | Logster.config.enable_js_error_reporting = false 49 | 50 | reporter = Logster::Middleware::Reporter.new(nil) 51 | env = Rack::MockRequest.env_for("/logs/report_js_error?message=hello") 52 | status, = reporter.call(env) 53 | 54 | assert_equal(403, status) 55 | assert_equal(0, Logster.store.count) 56 | end 57 | 58 | def test_rate_limiting 59 | reporter = Logster::Middleware::Reporter.new(nil) 60 | env = Rack::MockRequest.env_for("/logs/report_js_error?message=hello") 61 | status, = reporter.call(env) 62 | 63 | assert_equal(200, status) 64 | assert_equal(1, Logster.store.count) 65 | 66 | reporter = Logster::Middleware::Reporter.new(nil) 67 | env = Rack::MockRequest.env_for("/logs/report_js_error?message=hello2") 68 | status, = reporter.call(env) 69 | 70 | assert_equal(429, status) 71 | assert_equal(1, Logster.store.count) 72 | 73 | reporter = Logster::Middleware::Reporter.new(nil) 74 | env = 75 | Rack::MockRequest.env_for( 76 | "/logs/report_js_error?message=hello2", 77 | "REMOTE_ADDR" => "100.1.1.2", 78 | ) 79 | status, = reporter.call(env) 80 | 81 | assert_equal(200, status) 82 | assert_equal(2, Logster.store.count) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /test/logster/test_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | require "logster/cache" 5 | 6 | class TestCache < Minitest::Test 7 | def setup 8 | @cache = Logster::Cache.new(5) 9 | end 10 | 11 | def test_cache_works 12 | prc = Proc.new { |key, value| @cache.fetch(key) { value } } 13 | value = "I should be retured" 14 | assert_equal(value, prc.call(:key1, value)) 15 | cached_value = value 16 | value = "I shouldn't be returned" 17 | assert_equal(cached_value, prc.call(:key1, value)) 18 | value2 = "value for key2" 19 | assert_equal(value2, prc.call(:key2, value2)) 20 | 21 | value = value2 = "Now I should be returned" 22 | Process.stub :clock_gettime, Process.clock_gettime(Process::CLOCK_MONOTONIC) + 6 do 23 | assert_equal(value, prc.call(:key1, value)) 24 | assert_equal(value2, prc.call(:key2, value2)) 25 | end 26 | end 27 | 28 | def test_cache_can_be_cleared 29 | value = "cached" 30 | prc = Proc.new { |key, val| @cache.fetch(key) { val } } 31 | assert_equal(value, prc.call(:key1, value)) 32 | assert_equal("v2", prc.call(:key2, "v2")) 33 | 34 | value = "new value" 35 | @cache.clear(:key1) 36 | assert_equal(value, prc.call(:key1, value)) 37 | assert_equal("v2", prc.call(:key2, "v2.2")) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/logster/test_defer_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | require "logster/defer_logger" 5 | require "logster/logger" 6 | 7 | class TestDeferLogger < Minitest::Test 8 | def setup 9 | @store = TestStore.new 10 | @defer_logger = Logster::DeferLogger.new(@store) 11 | end 12 | 13 | def test_defer_logger_inherits_logger 14 | assert(Logster::Logger === @defer_logger) 15 | end 16 | 17 | def test_work_is_done_async 18 | queue = Logster::Scheduler.queue 19 | assert_equal(0, queue.size) 20 | 21 | @defer_logger.add(4, "hi this a test", "prog") 22 | 23 | assert_equal(1, queue.size) 24 | queue << :terminate 25 | Logster::Scheduler.thread.join 26 | assert_equal(1, @store.calls.size) 27 | 28 | # we need to make sure the backtrace is passed from the main thread. 29 | # Otherwise we'd only get a partial backtrace from 30 | # the point the new thread was spawned 31 | backtrace = @store.calls.first[3][:backtrace] 32 | assert_includes(backtrace.lines.first, __method__.to_s) 33 | 34 | assert_equal(0, queue.size) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/logster/test_group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | require "logster/group" 5 | require "logster/message" 6 | 7 | class TestGroup < MiniTest::Test 8 | def test_changed_is_true_for_new_instances 9 | assert Logster::Group.new("/somekey/").changed? 10 | end 11 | 12 | def test_from_json_works_correctly 13 | time = (Time.now.to_f * 1000).to_i - 5000 14 | json = 15 | JSON.generate( 16 | key: "/somekey/", 17 | messages_keys: [111, 222, 333].map(&:to_s), 18 | timestamp: time, 19 | count: 3, 20 | ) 21 | group = Logster::Group.from_json(json) 22 | refute group.changed? 23 | assert_equal 3, group.count 24 | assert_equal time, group.timestamp 25 | end 26 | 27 | def test_doesnt_add_duplicate_messages 28 | group = get_group 29 | msg1 = get_message 30 | assert_equal 0, group.count 31 | group.add_message(msg1) 32 | assert_equal 1, group.count 33 | assert_equal msg1.timestamp, group.timestamp 34 | group.add_message(msg1) 35 | assert_equal 1, group.count 36 | 37 | msg2 = get_message 38 | msg2.timestamp -= 10_000 39 | group.add_message(msg2) 40 | assert_equal 2, group.count 41 | assert_equal msg1.timestamp, group.timestamp 42 | end 43 | 44 | def test_adding_multiple_messages_works_correctly 45 | group = get_group 46 | messages = [get_message(10), get_message(5), get_message(74), get_message(26)] 47 | messages << messages[0] 48 | group.messages = messages 49 | group.count = 4 50 | assert_equal 4, group.count 51 | assert_equal 74, group.timestamp 52 | expected = messages.uniq(&:key).sort_by(&:timestamp).map(&:key).reverse 53 | assert_equal expected, group.messages_keys 54 | end 55 | 56 | def test_doesnt_exceed_max_size 57 | Logster::Group.instance_variable_set(:@max_size, 5) 58 | group = get_group 59 | messages = [ 60 | get_message(10), 61 | get_message(5), 62 | get_message(74), 63 | get_message(26), 64 | get_message(44), 65 | get_message(390), 66 | ] 67 | messages.each { |m| group.add_message(m) } 68 | # the count attr keeps track of the number of messages 69 | # that has ever been added to the group. 70 | # It should never decrease 71 | assert_equal 6, group.count 72 | assert_equal 390, group.timestamp 73 | refute_includes group.messages_keys, messages.find { |m| m.timestamp == 10 }.key 74 | 75 | group = get_group 76 | group.messages = messages 77 | assert_equal 390, group.timestamp 78 | refute_includes group.messages.map(&:timestamp), 5 79 | ensure 80 | Logster::Group.remove_instance_variable(:@max_size) 81 | end 82 | 83 | private 84 | 85 | def get_group 86 | Logster::Group.new("/groupkey/") 87 | end 88 | 89 | def get_message(timestamp = nil) 90 | Logster::Message.new(0, "", "testmessage", timestamp) 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/logster/test_ignore_pattern.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | require "logster/ignore_pattern" 5 | 6 | class TestIgnorePattern < Minitest::Test 7 | def test_string_message_pattern 8 | msg = Logster::Message.new(Logger::WARN, "test", "my error (oh no!)") 9 | msg_frog = Logster::Message.new(Logger::WARN, "test", "a frog") 10 | msg_nil = Logster::Message.new(Logger::WARN, "test", nil) 11 | 12 | pattern = Logster::IgnorePattern.new("my error (") 13 | 14 | assert pattern.matches? msg 15 | assert !pattern.matches?(msg_frog) 16 | assert !pattern.matches?(msg_nil) 17 | end 18 | 19 | def test_env_pattern 20 | msg = Logster::Message.new(Logger::WARN, "test", "my error") 21 | msg.env = { "frogs" => "are big" } 22 | 23 | pattern = Logster::IgnorePattern.new(nil, frogs: "big") 24 | 25 | assert pattern.matches? msg 26 | 27 | msg.env = { legs: nil } 28 | assert !(pattern.matches? msg) 29 | 30 | msg.env = { legs: 3 } 31 | assert !(pattern.matches? msg) 32 | 33 | msg.env = { frogs: "small" } 34 | assert !pattern.matches?(msg) 35 | 36 | pattern = Logster::IgnorePattern.new(nil, "small") 37 | assert pattern.matches? msg 38 | 39 | msg.env = { frogs: "big" } 40 | assert !(pattern.matches? msg) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/logster/test_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | require "logster/logger" 5 | require "logger" 6 | 7 | class TestStore < Logster::BaseStore 8 | attr_accessor :calls 9 | 10 | def report(*args) 11 | (@calls ||= []) << args 12 | end 13 | end 14 | 15 | class TestLogger < Minitest::Test 16 | def setup 17 | @store = TestStore.new 18 | @logger = Logster::Logger.new(@store) 19 | end 20 | 21 | def test_only_logs_valid_encoding 22 | @logger.add(4, "a \xE4 test", "prog") 23 | _, _, message = @store.calls[0] 24 | assert_equal true, message.valid_encoding? 25 | end 26 | 27 | def test_per_thread_override 28 | logger2 = Logster::Logger.new(@store) 29 | logger2.override_level = 2 30 | 31 | # we should not leak between objects 32 | assert_nil @logger.override_level 33 | 34 | @logger.override_level = 2 35 | 36 | @logger.add(0, "test", "prog", backtrace: "backtrace", env: { a: "x" }) 37 | Thread.new { @logger.add(0, "test", "prog", backtrace: "backtrace", env: { a: "x" }) }.join 38 | 39 | @logger.override_level = nil 40 | @logger.add(0, "test", "prog", backtrace: "backtrace", env: { a: "x" }) 41 | 42 | assert_equal 2, @store.calls.length 43 | end 44 | 45 | def test_backtrace 46 | @logger.add(0, "test", "prog", backtrace: "backtrace", env: { a: "x" }) 47 | assert_equal "backtrace", @store.calls[0][3][:backtrace] 48 | end 49 | 50 | def test_chain 51 | io = StringIO.new 52 | @logger.chain Logger.new(io) 53 | @logger.warn "boom" 54 | 55 | assert_match(/W,.*boom/, io.string) 56 | end 57 | 58 | def test_backtrace_with_chain 59 | @other_store = TestStore.new 60 | @logger.chain(Logster::Logger.new(@other_store)) 61 | 62 | @logger.add(0, "test", "prog", backtrace: "backtrace", env: { a: "x" }) 63 | 64 | [@store, @other_store].each { |store| assert_equal "backtrace", store.calls[0][3][:backtrace] } 65 | end 66 | 67 | def test_add_with_one_argument 68 | @logger.add(2) { "test" } 69 | @logger.add(2) 70 | assert_equal 2, @store.calls.length 71 | assert_equal "test", @store.calls.first[2] 72 | end 73 | 74 | def test_subscribing_to_logger_events 75 | custom_logger_klass = 76 | Class.new do 77 | attr_reader :events 78 | 79 | def initialize 80 | @events = [] 81 | end 82 | 83 | def log(severity, message, progname, opts, &block) 84 | @events.push({ severity:, message:, progname:, opts:, block: }) 85 | end 86 | end 87 | 88 | custom_logger = custom_logger_klass.new 89 | 90 | @logger.subscribe do |severity, message, progname, opts, &block| 91 | custom_logger.log(severity, message, progname, opts, &block) 92 | end 93 | 94 | @logger.add(0, "test", "prog", backtrace: "backtrace", env: { a: "x" }) 95 | @logger.add(1, nil, nil, backtrace: "backtrace") { "yielded message" } 96 | 97 | first_event = custom_logger.events[0] 98 | 99 | assert_equal(0, first_event[:severity]) 100 | assert_equal("test", first_event[:message]) 101 | assert_equal("prog", first_event[:progname]) 102 | assert_equal({ backtrace: "backtrace", env: { a: "x" } }, first_event[:opts]) 103 | assert_nil first_event[:block] 104 | 105 | second_event = custom_logger.events[1] 106 | 107 | assert_equal(1, second_event[:severity]) 108 | assert_nil second_event[:message] 109 | assert_nil second_event[:progname] 110 | assert_equal({ backtrace: "backtrace", env: nil }, second_event[:opts]) 111 | assert_equal("yielded message", second_event[:block].call) 112 | end 113 | 114 | class NewLogger < Logster::Logger 115 | end 116 | 117 | def test_inherited_logger_backtrace_with_chain 118 | @other_store = TestStore.new 119 | @logger = NewLogger.new(@store) 120 | @logger.chain(Logster::Logger.new(@other_store)) 121 | 122 | @logger.add(0, "test", "prog", backtrace: "backtrace", env: { a: "x" }) 123 | 124 | [@store, @other_store].each { |store| assert_equal "backtrace", store.calls[0][3][:backtrace] } 125 | end 126 | 127 | def test_progname_parameter 128 | @logger.add(0, "test") 129 | progname = @store.calls[0][1] 130 | assert_nil progname 131 | end 132 | 133 | class PlayLogger 134 | attr_accessor :skip_store 135 | def initialize(tester) 136 | @tester = tester 137 | end 138 | 139 | def add(s, m, p, &block) 140 | @tester.assert(skip_store) 141 | end 142 | end 143 | 144 | def test_chain_with_ignore 145 | @logger.chain PlayLogger.new(self) 146 | @logger.skip_store = true 147 | @logger.warn("testing") 148 | end 149 | 150 | def test_logging_an_error_gets_backtrace_from_the_error 151 | exception = error_instance(Exception) 152 | std_err = error_instance(StandardError) 153 | custom_err = error_instance(Class.new(StandardError)) 154 | 155 | @logger.error(exception) 156 | @logger.fatal(std_err) 157 | @logger.fatal(custom_err) 158 | 159 | assert_equal exception.backtrace.join("\n"), @store.calls[0][3][:backtrace] 160 | assert_equal std_err.backtrace.join("\n"), @store.calls[1][3][:backtrace] 161 | assert_equal custom_err.backtrace.join("\n"), @store.calls[2][3][:backtrace] 162 | end 163 | 164 | def test_formatter 165 | @logger.formatter = ->(severity, datetime, progname, msg) { "[test] #{msg}" } 166 | @logger.add(0, "hello") 167 | assert_equal "[test] hello", @store.calls[0][2] 168 | end 169 | 170 | private 171 | 172 | def error_instance(error_class) 173 | raise error_class.new 174 | rescue error_class => e 175 | e 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /test/logster/test_pattern.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | require "logster/redis_store" 5 | require "logster/pattern" 6 | 7 | class TestPattern < Minitest::Test 8 | class FakePattern < Logster::Pattern 9 | def self.set_name 10 | "__LOGSTER__fake_patterns_set".freeze 11 | end 12 | end 13 | 14 | class TestRedisStore < Logster::BaseStore 15 | def get_patterns(set_name) 16 | ["/differentstore/"] 17 | end 18 | end 19 | 20 | def setup 21 | Logster.store = Logster::RedisStore.new 22 | Logster.store.clear_all 23 | end 24 | 25 | def teardown 26 | Logster.store.clear_all 27 | Logster.store = nil 28 | end 29 | 30 | def test_parse_pattern_works_correctly 31 | assert_equal(/osama/i, klass.parse_pattern(/osama/i)) 32 | assert_equal(/osama/i, klass.parse_pattern("/osama/i")) 33 | assert_equal(/osama/, klass.parse_pattern("/osama/")) 34 | assert_equal(/osama/, klass.parse_pattern("osama")) 35 | assert_equal(/[a-zA-Z]/, klass.parse_pattern("[a-zA-Z]")) 36 | assert_equal(/[a-zA-Z]/, klass.parse_pattern("/[a-zA-Z]/")) 37 | 38 | assert_nil(klass.parse_pattern("/osama")) 39 | assert_nil(klass.parse_pattern("[")) 40 | assert_nil(klass.parse_pattern("/[/")) 41 | end 42 | 43 | def test_validity_checks_are_correct 44 | assert(klass.valid?(/osama/)) 45 | refute(klass.valid?(//)) 46 | refute(klass.valid?(//i)) 47 | refute(klass.valid?(/ /)) 48 | end 49 | 50 | def test_find_all_works_correctly 51 | patterns = ["/test/i", "tttt", "[d-y].*"] 52 | patterns.each { |p| FakePattern.new(p).save } 53 | 54 | results = FakePattern.find_all 55 | assert_equal(3, results.size) 56 | assert_includes(results, /test/i) 57 | assert_includes(results, /tttt/) 58 | assert_includes(results, /[d-y].*/) 59 | 60 | results = FakePattern.find_all(raw: true) 61 | assert_equal(3, results.size) 62 | assert_includes(results, "/test/i") 63 | assert_includes(results, "/tttt/") 64 | assert_includes(results, "/[d-y].*/") 65 | end 66 | 67 | def test_find_all_can_take_an_instance_of_store 68 | results = FakePattern.find_all(store: TestRedisStore.new) 69 | assert_equal(1, results.size) 70 | assert_equal(/differentstore/, results.first) 71 | end 72 | 73 | def test_find_works_correctly 74 | FakePattern.new("/wwwlll/").save 75 | 76 | record = FakePattern.find("wwwlll") 77 | assert_equal(/wwwlll/, record.pattern) 78 | record = FakePattern.find(/wwwlll/) 79 | assert_equal(/wwwlll/, record.pattern) 80 | 81 | assert_nil(FakePattern.find("dfsdfsdf")) 82 | assert_nil(FakePattern.find(nil)) 83 | end 84 | 85 | def test_patterns_get_parsed_on_initialize 86 | assert_equal(/mypattern/, FakePattern.new("mypattern").pattern) 87 | assert_equal(/111333/, FakePattern.new(/111333/).pattern) 88 | end 89 | 90 | def test_save_works_correctly 91 | bad_patterns = ["/bruken", nil, "[a-z", "/(osa|sss{1/"] 92 | bad_patterns.each do |p| 93 | assert_raises(Logster::Pattern::PatternError) { FakePattern.new(p).save } 94 | end 95 | assert_equal(0, FakePattern.find_all.size) 96 | 97 | good_patterns = ["/logster/i", /logster/, "sssd", "(ccx|tqe){1,5}", "logster"] 98 | good_patterns.each { |p| FakePattern.new(p).save } 99 | results = FakePattern.find_all 100 | assert_equal(4, results.size) # 4 because /logster/ and logster are the same 101 | good_patterns_regex = [/logster/i, /logster/, /sssd/, /(ccx|tqe){1,5}/] 102 | results.each { |p| assert_includes(good_patterns_regex, p) } 103 | end 104 | 105 | def test_modify_works_correctly 106 | record = FakePattern.new(/logster/) 107 | record.save 108 | 109 | record.modify("/LoGsTEr/") 110 | all_patterns = FakePattern.find_all 111 | assert_equal(1, all_patterns.size) 112 | assert_equal(/LoGsTEr/, all_patterns.first) 113 | assert_equal(/LoGsTEr/, record.pattern) 114 | end 115 | 116 | def test_modify_doesnt_remove_old_pattern_when_new_is_bad 117 | record = FakePattern.new(/LoGsTEr/) 118 | record.save 119 | 120 | assert_raises(Logster::Pattern::PatternError) { record.modify("/badReg") } 121 | all_patterns = FakePattern.find_all 122 | assert_equal(1, all_patterns.size) 123 | assert_equal(/LoGsTEr/, all_patterns.first) 124 | assert_equal(/LoGsTEr/, record.pattern) 125 | end 126 | 127 | def test_destroy_works_correctly 128 | record = FakePattern.new(/somepattern/) 129 | record.save 130 | 131 | patterns = FakePattern.find_all 132 | assert_equal(1, patterns.size) 133 | assert_equal(/somepattern/, patterns.first) 134 | 135 | record.destroy 136 | assert_equal(0, FakePattern.find_all.size) 137 | end 138 | 139 | private 140 | 141 | def klass 142 | Logster::Pattern 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /test/logster/test_railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV["RAILS_ENV"] = "test" 4 | 5 | require "redis" 6 | require_relative "../dummy/config/environment" 7 | ActiveRecord::Migrator.migrations_paths = [File.expand_path("dummy/db/migrate", __dir__)] 8 | 9 | require_relative "../test_helper" 10 | 11 | class TestRailtie < Minitest::Test 12 | def test_sets_logger 13 | refute_nil Logster.logger 14 | 15 | if Rails.version >= "7.1" 16 | assert_includes Rails.logger.broadcasts, Logster.logger 17 | else 18 | assert_equal Rails.logger, Logster.logger 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/logster/test_redis_rate_limiter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | require "logster/redis_store" 5 | require "rack" 6 | 7 | class TestRedisRateLimiter < Minitest::Test 8 | def setup 9 | @redis = Redis.new 10 | end 11 | 12 | def teardown 13 | @redis.flushall 14 | Timecop.return 15 | end 16 | 17 | def test_clear_all 18 | called = 0 19 | 20 | @redis.set("dont_nuke", "1") 21 | 22 | @rate_limiter = 23 | Logster::RedisRateLimiter.new( 24 | @redis, 25 | [Logger::WARN], 26 | 8, 27 | 60, 28 | Proc.new { "prefix" }, 29 | Proc.new { called += 1 }, 30 | ) 31 | 32 | 9.times { @rate_limiter.check(Logger::WARN) } 33 | 34 | assert_equal 10, @rate_limiter.check(Logger::WARN) 35 | 36 | Logster::RedisRateLimiter.clear_all(@redis, Proc.new { "prefix" }) 37 | 38 | assert_equal 1, @rate_limiter.check(Logger::WARN) 39 | 40 | # also clears when prefix missing 41 | Logster::RedisRateLimiter.clear_all(@redis) 42 | 43 | assert_equal 1, @rate_limiter.check(Logger::WARN) 44 | 45 | assert_equal "1", @redis.get("dont_nuke") 46 | @redis.del("dont_nuke") 47 | end 48 | 49 | def test_check 50 | time = Time.new(2015, 1, 1, 1, 1) 51 | Timecop.freeze(time) 52 | called = 0 53 | 54 | @rate_limiter = 55 | Logster::RedisRateLimiter.new(@redis, [Logger::WARN], 8, 60, nil, Proc.new { called += 1 }) 56 | 57 | assert_equal(1, @rate_limiter.check(Logger::WARN)) 58 | assert_redis_key(60, 0) 59 | assert_equal(1, number_of_buckets) 60 | 61 | Timecop.freeze(time + 10) do 62 | assert_equal(2, @rate_limiter.check(Logger::WARN)) 63 | assert_redis_key(60, 1) 64 | assert_equal(3, @rate_limiter.check(Logger::WARN)) 65 | assert_equal(2, number_of_buckets) 66 | end 67 | 68 | Timecop.freeze(time + 20) do 69 | assert_equal(4, @rate_limiter.check(Logger::WARN)) 70 | assert_redis_key(60, 2) 71 | assert_equal(3, number_of_buckets) 72 | end 73 | 74 | Timecop.freeze(time + 30) do 75 | assert_equal(5, @rate_limiter.check(Logger::WARN)) 76 | assert_redis_key(60, 3) 77 | assert_equal(4, number_of_buckets) 78 | end 79 | 80 | Timecop.freeze(time + 40) do 81 | assert_equal(6, @rate_limiter.check(Logger::WARN)) 82 | assert_redis_key(60, 4) 83 | assert_equal(5, number_of_buckets) 84 | end 85 | 86 | Timecop.freeze(time + 50) do 87 | assert_equal(7, @rate_limiter.check(Logger::WARN)) 88 | assert_redis_key(60, 5) 89 | assert_equal(6, number_of_buckets) 90 | end 91 | 92 | Timecop.freeze(time + 60) do 93 | @redis.del("#{key}:0") 94 | assert_equal(5, number_of_buckets) 95 | 96 | assert_equal(7, @rate_limiter.check(Logger::WARN)) 97 | assert_redis_key(60, 0) 98 | assert_equal(6, number_of_buckets) 99 | 100 | assert_equal(8, @rate_limiter.check(Logger::WARN)) 101 | assert_equal(1, called) 102 | assert_equal(6, number_of_buckets) 103 | assert_equal("1", @redis.get(@rate_limiter.callback_key)) 104 | end 105 | 106 | Timecop.freeze(time + 70) do 107 | @redis.del("#{key}:1") 108 | assert_equal(7, @rate_limiter.check(Logger::WARN)) 109 | assert_nil(@redis.get(@rate_limiter.callback_key)) 110 | end 111 | end 112 | 113 | def test_check_with_multiple_severities 114 | time = Time.new(2015, 1, 1, 1, 1) 115 | Timecop.freeze(time) 116 | called = 0 117 | 118 | @rate_limiter = 119 | Logster::RedisRateLimiter.new( 120 | @redis, 121 | [Logger::WARN, Logger::ERROR], 122 | 4, 123 | 60, 124 | nil, 125 | Proc.new { called += 1 }, 126 | ) 127 | 128 | assert_equal(1, @rate_limiter.check(Logger::WARN)) 129 | assert_equal(2, @rate_limiter.check(Logger::ERROR)) 130 | 131 | Timecop.freeze(time + 50) do 132 | assert_equal(3, @rate_limiter.check(Logger::WARN)) 133 | assert_equal(4, @rate_limiter.check(Logger::ERROR)) 134 | assert_equal(2, number_of_buckets) 135 | end 136 | 137 | assert_equal(5, @rate_limiter.check(Logger::ERROR)) 138 | assert_equal(1, called) 139 | end 140 | 141 | def test_bucket_number_per_minute 142 | time = Time.new(2015, 1, 1, 1, 1) 143 | Timecop.freeze(time) 144 | @rate_limiter = Logster::RedisRateLimiter.new(@redis, [Logger::WARN], 1, 60) 145 | 146 | assert_bucket_number(0, time) 147 | assert_bucket_number(0, time + 9) 148 | assert_bucket_number(1, time + 11) 149 | assert_bucket_number(5, time + 59) 150 | end 151 | 152 | def test_bucket_number_per_hour 153 | time = Time.new(2015, 1, 1, 1, 0) 154 | Timecop.freeze(time) 155 | @rate_limiter = Logster::RedisRateLimiter.new(@redis, [Logger::WARN], 1, 3600) 156 | 157 | assert_bucket_number(0, time) 158 | assert_bucket_number(1, time + 1199) 159 | assert_bucket_number(2, time + 1200) 160 | assert_bucket_number(5, time + 3599) 161 | end 162 | 163 | def test_bucket_expiry 164 | time = Time.new(2015, 1, 1, 1, 1) 165 | Timecop.freeze(time) 166 | @rate_limiter = Logster::RedisRateLimiter.new(@redis, [Logger::WARN], 1, 60) 167 | 168 | assert_bucket_expiry(60, time) 169 | assert_bucket_expiry(55, time + 5) 170 | assert_bucket_expiry(60, time + 10) 171 | assert_bucket_expiry(58, time + 12) 172 | assert_bucket_expiry(55, time + 15) 173 | assert_bucket_expiry(51, time + 19) 174 | assert_bucket_expiry(60, time + 20) 175 | assert_bucket_expiry(55, time + 35) 176 | end 177 | 178 | def test_raw_connection 179 | time = Time.new(2015, 1, 1, 1, 1) 180 | Timecop.freeze(time) 181 | @rate_limiter = 182 | Logster::RedisRateLimiter.new(@redis, [Logger::WARN], 1, 60, Proc.new { "lobster" }) 183 | 184 | assert_equal(1, @rate_limiter.check(Logger::WARN)) 185 | assert_redis_key(60, 0) 186 | 187 | toggle = true 188 | 189 | @rate_limiter = 190 | Logster::RedisRateLimiter.new( 191 | @redis, 192 | [Logger::WARN], 193 | 1, 194 | 60, 195 | Proc.new { toggle ? "lobster1" : "lobster2" }, 196 | ) 197 | 198 | assert_includes(key, "lobster1") 199 | 200 | toggle = false 201 | assert_includes(key, "lobster2") 202 | end 203 | 204 | def test_retrieve_rate 205 | time = Time.new(2015, 1, 1, 1, 1) 206 | Timecop.freeze(time) 207 | 208 | @rate_limiter = Logster::RedisRateLimiter.new(@redis, [Logger::WARN], 1, 60) 209 | 210 | @rate_limiter.check(Logger::WARN) 211 | assert_equal(@rate_limiter.retrieve_rate, 1) 212 | 213 | Timecop.freeze(time + 50) do 214 | @rate_limiter.check(Logger::WARN) 215 | assert_equal(@rate_limiter.retrieve_rate, 2) 216 | end 217 | end 218 | 219 | private 220 | 221 | def key 222 | @rate_limiter.key 223 | end 224 | 225 | def number_of_buckets 226 | @redis.keys("#{key}:[0-#{Logster::RedisRateLimiter::BUCKETS}]").size 227 | end 228 | 229 | def assert_bucket_number(expected, time) 230 | Timecop.freeze(time) do 231 | assert_equal(expected, @rate_limiter.send(:bucket_number, Time.now.to_i)) 232 | end 233 | end 234 | 235 | def assert_bucket_expiry(expected, time) 236 | Timecop.freeze(time) do 237 | assert_equal(expected, @rate_limiter.send(:bucket_expiry, Time.now.to_i)) 238 | end 239 | end 240 | 241 | def assert_redis_key(expected_ttl, expected_bucket_number) 242 | redis_key = "#{key}:#{expected_bucket_number}" 243 | assert(@redis.get(redis_key), "the right bucket should be created") 244 | assert_equal(expected_ttl, @redis.ttl(redis_key)) 245 | end 246 | end 247 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "minitest" 4 | require "minitest/unit" 5 | require "minitest/autorun" 6 | require "minitest/pride" 7 | require "redis" 8 | require "logster" 9 | require "logster/base_store" 10 | require "timecop" 11 | require "byebug" 12 | 13 | class Logster::TestStore < Logster::BaseStore 14 | attr_accessor :reported 15 | def initialize 16 | super 17 | @reported = [] 18 | end 19 | 20 | def save(message) 21 | @reported << message 22 | end 23 | 24 | def count 25 | @reported.count 26 | end 27 | 28 | def clear 29 | @reported = [] 30 | end 31 | 32 | def clear_all 33 | @reported = [] 34 | end 35 | 36 | def check_rate_limits(severity) 37 | # Do nothing 38 | end 39 | 40 | def increment_ignore_count(pattern) 41 | end 42 | 43 | # get, protect, unprotect: unimplemented 44 | end 45 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/logster.js.erb: -------------------------------------------------------------------------------- 1 | (function() { 2 | var lastReport = null; 3 | 4 | if (!window.Logster) { 5 | window.Logster = { 6 | enabled: true 7 | }; 8 | } 9 | 10 | window.onerror = function(message, url, line, column, errorObj) { 11 | // never bother reporting more than once a minute 12 | if (lastReport && new Date() - lastReport < 1000 * 60) { 13 | return; 14 | } 15 | if (!Logster.enabled) { 16 | return; 17 | } 18 | 19 | lastReport = new Date(); 20 | 21 | var err = { 22 | message: message, 23 | url: url, 24 | line: line, 25 | column: column, 26 | window_location: window.location && (window.location + "") 27 | }; 28 | 29 | if (errorObj && errorObj.stack) { 30 | err.stacktrace = errorObj.stack; 31 | } 32 | 33 | $.ajax("<%= Logster.config.subdirectory %>" + "/report_js_error", { 34 | data: err, 35 | type: "POST", 36 | cache: false 37 | }); 38 | }; 39 | })(); 40 | -------------------------------------------------------------------------------- /website/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'sinatra' 6 | gem 'redis' 7 | gem 'logster', path: '../' 8 | gem 'puma' 9 | -------------------------------------------------------------------------------- /website/Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | logster (2.20.1) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | base64 (0.2.0) 10 | connection_pool (2.5.0) 11 | logger (1.6.6) 12 | mustermann (3.0.3) 13 | ruby2_keywords (~> 0.0.1) 14 | nio4r (2.7.4) 15 | puma (6.6.0) 16 | nio4r (~> 2.0) 17 | rack (3.1.10) 18 | rack-protection (4.1.1) 19 | base64 (>= 0.1.0) 20 | logger (>= 1.6.0) 21 | rack (>= 3.0.0, < 4) 22 | rack-session (2.1.0) 23 | base64 (>= 0.1.0) 24 | rack (>= 3.0.0) 25 | redis (5.3.0) 26 | redis-client (>= 0.22.0) 27 | redis-client (0.23.2) 28 | connection_pool 29 | ruby2_keywords (0.0.5) 30 | sinatra (4.1.1) 31 | logger (>= 1.6.0) 32 | mustermann (~> 3.0) 33 | rack (>= 3.0.0, < 4) 34 | rack-protection (= 4.1.1) 35 | rack-session (>= 2.0.0, < 3) 36 | tilt (~> 2.0) 37 | tilt (2.6.0) 38 | 39 | PLATFORMS 40 | ruby 41 | 42 | DEPENDENCIES 43 | logster! 44 | puma 45 | redis 46 | sinatra 47 | 48 | BUNDLED WITH 49 | 2.5.3 50 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | ## Sample Website 2 | 3 | This website is running at [logster.info](http://logster.info). 4 | 5 | To start it and test your code changes: 6 | 7 | ```text 8 | $ bundle install 9 | $ bundle exec rackup 10 | ``` 11 | -------------------------------------------------------------------------------- /website/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './sample' 4 | run Sample 5 | -------------------------------------------------------------------------------- /website/docker_container/logster.yml: -------------------------------------------------------------------------------- 1 | base_image: "discourse/base:release" 2 | 3 | update_pups: false 4 | 5 | params: 6 | home: /var/www/logster 7 | 8 | templates: 9 | - "templates/redis.template.yml" 10 | 11 | expose: 12 | - "8081:80" 13 | 14 | volumes: 15 | - volume: 16 | host: /var/docker/shared/logster 17 | guest: /shared 18 | 19 | hooks: 20 | after_redis: 21 | - exec: gem install bundler 22 | - exec: 23 | cmd: 24 | - useradd logster -s /bin/bash -m -U 25 | - exec: 26 | background: true 27 | cmd: "sudo -u redis /usr/bin/redis-server /etc/redis/redis.conf --dbfilename test.rdb" 28 | - exec: mkdir -p /var/www 29 | - exec: cd /var/www && git clone --depth 1 https://github.com/SamSaffron/logster.git 30 | - exec: 31 | cd: $home 32 | cmd: 33 | - chown -R logster $home 34 | - mkdir -p /shared/gems 35 | - chown -R logster /shared/gems 36 | - sudo -E -u logster bundle install --path=/shared/gems --verbose 37 | - sudo -E -u logster bundle exec rake 38 | - exec: 39 | cd: $home/website 40 | cmd: 41 | - sudo -E -u logster bundle install --path=/shared/gems --verbose 42 | - file: 43 | path: /etc/service/puma/run 44 | chmod: "+x" 45 | contents: | 46 | #!/bin/bash 47 | exec 2>&1 48 | # redis 49 | cd $home/website 50 | exec sudo -E -u logster LD_PRELOAD=/usr/lib/libjemalloc.so.1 bundle exec puma -p 8080 -e production 51 | - exec: rm /etc/nginx/sites-enabled/default 52 | - replace: 53 | filename: /etc/nginx/nginx.conf 54 | from: pid /run/nginx.pid; 55 | to: daemon off; 56 | - file: 57 | path: /etc/nginx/conf.d/logster.conf 58 | contents: | 59 | upstream logster { 60 | server localhost:8080; 61 | } 62 | server { 63 | listen 80; 64 | gzip on; 65 | gzip_types application/json text/css application/x-javascript; 66 | gzip_min_length 1000; 67 | server_name logster.info; 68 | keepalive_timeout 65; 69 | location ~ ^/logs(?/[^\?]+).* { 70 | root /var/www/logster/assets; 71 | expires 1y; 72 | add_header ETag ""; 73 | add_header Cache-Control public; 74 | try_files $relative @logster; 75 | } 76 | location / { 77 | try_files $uri @logster; 78 | } 79 | location @logster { 80 | proxy_set_header Host $http_host; 81 | proxy_set_header X-Real-IP $remote_addr; 82 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 83 | proxy_set_header X-Forwarded-Proto http; 84 | proxy_pass http://logster; 85 | } 86 | } 87 | - file: 88 | path: /etc/service/nginx/run 89 | chmod: "+x" 90 | contents: | 91 | #!/bin/sh 92 | exec 2>&1 93 | exec /usr/sbin/nginx 94 | -------------------------------------------------------------------------------- /website/docker_container/update_logster: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | result=`cd /root/logster && git pull | grep "Already up-to-date"` 4 | 5 | if [ -z "$result" ]; then 6 | echo "updating..." 7 | cd /var/docker && ./launcher bootstrap logster && ./launcher destroy logster && ./launcher start logster 8 | fi 9 | 10 | -------------------------------------------------------------------------------- /website/images/icon_144x144.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/logster/4209863d0c9ad8f52ec94c11f6d9215cb9885b65/website/images/icon_144x144.ai -------------------------------------------------------------------------------- /website/images/icon_64x64.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/logster/4209863d0c9ad8f52ec94c11f6d9215cb9885b65/website/images/icon_64x64.ai -------------------------------------------------------------------------------- /website/images/logo-logster-cropped-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/logster/4209863d0c9ad8f52ec94c11f6d9215cb9885b65/website/images/logo-logster-cropped-small.png -------------------------------------------------------------------------------- /website/images/logo-logster-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/logster/4209863d0c9ad8f52ec94c11f6d9215cb9885b65/website/images/logo-logster-cropped.png -------------------------------------------------------------------------------- /website/images/logo_logster_CMYK.eps: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/logster/4209863d0c9ad8f52ec94c11f6d9215cb9885b65/website/images/logo_logster_CMYK.eps -------------------------------------------------------------------------------- /website/images/logo_logster_RGB.eps: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/logster/4209863d0c9ad8f52ec94c11f6d9215cb9885b65/website/images/logo_logster_RGB.eps -------------------------------------------------------------------------------- /website/images/logo_logster_RGB.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/logster/4209863d0c9ad8f52ec94c11f6d9215cb9885b65/website/images/logo_logster_RGB.jpg -------------------------------------------------------------------------------- /website/images/logster-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/logster/4209863d0c9ad8f52ec94c11f6d9215cb9885b65/website/images/logster-screenshot.png -------------------------------------------------------------------------------- /website/sample.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Run with 'bundle exec rackup' 4 | 5 | require "redis" 6 | require "logster" 7 | require "logster/middleware/reporter" 8 | require "logster/middleware/viewer" 9 | require "json" 10 | require "sinatra" 11 | require "sinatra/base" 12 | 13 | # log a few errors 14 | SAMPLE_REDIS = Redis.new 15 | SAMPLE_STORE = Logster.store = Logster::RedisStore.new(SAMPLE_REDIS) 16 | Logster.logger = Logster::Logger.new(SAMPLE_STORE) 17 | 18 | class SampleLoader 19 | def initialize 20 | @index = 0 21 | @sample_data_key = "sample_data" 22 | end 23 | 24 | def ensure_samples_loaded 25 | SAMPLE_REDIS.del @sample_data_key 26 | data = File.read("data/data.json") 27 | parsed = JSON.parse(data) 28 | parsed.each { |row| SAMPLE_REDIS.rpush @sample_data_key, JSON.fast_generate(row) } 29 | @length = parsed.length 30 | end 31 | 32 | def load_samples 33 | Thread.new do 34 | while true 35 | sleep 5 36 | begin 37 | load_next_sample 38 | rescue => e 39 | SAMPLE_STORE.report(4, "logster", e.to_s) 40 | end 41 | end 42 | end 43 | end 44 | 45 | def load_next_sample 46 | message = JSON.parse(SAMPLE_REDIS.lindex(@sample_data_key, @index)) 47 | @index += 1 48 | @index %= @length 49 | 50 | SAMPLE_STORE.report( 51 | message["severity"], 52 | message["progname"], 53 | message["message"], 54 | backtrace: message["backtrace"], 55 | env: message["env"], 56 | count: message["count"], 57 | ) 58 | end 59 | 60 | def load_error 61 | # 2 = Severity.WARN 62 | params = {} 63 | params["always_present"] = "some_value_#{rand(3)}" 64 | params["key_#{rand(3)}"] = "some_value_#{rand(3)}" 65 | SAMPLE_STORE.report( 66 | 2, 67 | "", 68 | "Message message message", 69 | backtrace: "Backtrace backtrace backtrace", 70 | env: { 71 | something: :foo, 72 | random: rand(3), 73 | array: [1, 2, 3], 74 | rand_array: [10, 11, rand(300)], 75 | params: params, 76 | }, 77 | ) 78 | end 79 | end 80 | 81 | SampleLoaderInstance = SampleLoader.new 82 | SampleLoaderInstance.ensure_samples_loaded 83 | SampleLoaderInstance.load_samples unless ENV["NO_DATA"] 84 | Logster.config.allow_grouping = true 85 | Logster.config.enable_custom_patterns_via_ui = ENV["LOGSTER_ENABLE_CUSTOM_PATTERNS_VIA_UI"] == "1" 86 | Logster.config.application_version = "b329e23f8511b7248c0e4aee370a9f8a249e1b84" 87 | Logster.config.gems_dir = "/home/sam/.rbenv/versions/2.1.2.discourse/lib/ruby/gems/2.1.0/gems/" 88 | Logster.config.project_directories = [ 89 | { 90 | path: "/home/sam/Source/discourse", 91 | url: "https://github.com/discourse/discourse", 92 | main_app: true, 93 | }, 94 | ] 95 | 96 | class Sample < Sinatra::Base 97 | use Logster::Middleware::Viewer 98 | use Logster::Middleware::Reporter 99 | 100 | get "/" do 101 | < 103 | 104 | 105 | 106 |

Welcome to logster:

107 | 111 | 112 | 113 | HTML 114 | end 115 | 116 | get "/report_error" do 117 | SampleLoaderInstance.load_next_sample 118 | SampleLoaderInstance.load_error 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /website/scripts/persist_logs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "redis" 4 | require "logster/redis_store" 5 | require "logster/message" 6 | require "json" 7 | 8 | store = Logster::RedisStore.new(Redis.new) 9 | latest = store.latest(limit: 1000) 10 | 11 | json = JSON.generate(latest) 12 | 13 | path = File.expand_path("../../data/data.json", __FILE__) 14 | File.open(path, "w") { |f| f.write(json) } 15 | puts "Wrote #{latest.count} messages into #{path}" 16 | --------------------------------------------------------------------------------