├── .editorconfig ├── .github └── workflows │ └── commit-stage.yml ├── .gitignore ├── .sdkmanrc ├── LICENSE ├── README.md ├── chat-service ├── .editorconfig ├── .gitignore ├── build.gradle ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── thomasvitale │ │ │ └── chatservice │ │ │ ├── ChatServiceApplication.java │ │ │ ├── chat │ │ │ ├── DocumentChatController.java │ │ │ ├── DocumentChatService.java │ │ │ └── SimpleChatController.java │ │ │ ├── documents │ │ │ └── DocumentInitializer.java │ │ │ └── multitenancy │ │ │ ├── context │ │ │ ├── TenantContextHolder.java │ │ │ └── resolvers │ │ │ │ ├── HttpHeaderTenantResolver.java │ │ │ │ └── TenantResolver.java │ │ │ ├── exceptions │ │ │ ├── TenantNotFoundException.java │ │ │ └── TenantResolutionException.java │ │ │ ├── tenantdetails │ │ │ ├── PropertiesTenantDetailsService.java │ │ │ ├── TenantDetails.java │ │ │ ├── TenantDetailsProperties.java │ │ │ └── TenantDetailsService.java │ │ │ └── web │ │ │ └── TenantContextFilter.java │ └── resources │ │ ├── application.yml │ │ └── documents │ │ ├── story1.md │ │ └── story2.txt │ └── test │ └── java │ └── com │ └── thomasvitale │ └── chatservice │ ├── ChatServiceApplicationTests.java │ └── TestChatServiceApplication.java ├── compose.yml ├── edge-service ├── .editorconfig ├── .gitignore ├── build.gradle ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── thomasvitale │ │ │ └── edgeservice │ │ │ ├── EdgeServiceApplication.java │ │ │ ├── config │ │ │ └── SecurityConfig.java │ │ │ └── multitenancy │ │ │ ├── exceptions │ │ │ ├── TenantNotFoundException.java │ │ │ └── TenantResolutionException.java │ │ │ ├── gateway │ │ │ └── TenantGatewayFilterFactory.java │ │ │ ├── security │ │ │ ├── TenantAuthenticationEntryPoint.java │ │ │ └── TenantClientRegistrationRepository.java │ │ │ └── tenantdetails │ │ │ ├── PropertiesTenantDetailsService.java │ │ │ ├── TenantDetails.java │ │ │ ├── TenantDetailsProperties.java │ │ │ └── TenantDetailsService.java │ └── resources │ │ ├── application.yml │ │ └── logback-spring.xml │ └── test │ └── java │ └── com │ └── thomasvitale │ └── edgeservice │ └── EdgeServiceApplicationTests.java ├── instrument-service ├── .editorconfig ├── .gitignore ├── build.gradle ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── thomasvitale │ │ │ └── instrumentservice │ │ │ ├── InstrumentServiceApplication.java │ │ │ ├── config │ │ │ └── SecurityConfig.java │ │ │ ├── demo │ │ │ ├── DataConfig.java │ │ │ └── PathConfig.java │ │ │ ├── instrument │ │ │ ├── Instrument.java │ │ │ ├── InstrumentController.java │ │ │ └── InstrumentRepository.java │ │ │ └── multitenancy │ │ │ ├── context │ │ │ ├── TenantContextHolder.java │ │ │ └── resolvers │ │ │ │ ├── HttpHeaderTenantResolver.java │ │ │ │ └── TenantResolver.java │ │ │ ├── data │ │ │ ├── cache │ │ │ │ └── TenantKeyGenerator.java │ │ │ ├── flyway │ │ │ │ └── TenantFlywayMigrationInitializer.java │ │ │ └── hibernate │ │ │ │ ├── ConnectionProvider.java │ │ │ │ └── TenantIdentifierResolver.java │ │ │ ├── exceptions │ │ │ ├── TenantNotFoundException.java │ │ │ └── TenantResolutionException.java │ │ │ ├── security │ │ │ └── TenantAuthenticationManagerResolver.java │ │ │ ├── tenantdetails │ │ │ ├── PropertiesTenantDetailsService.java │ │ │ ├── TenantDetails.java │ │ │ ├── TenantDetailsProperties.java │ │ │ └── TenantDetailsService.java │ │ │ └── web │ │ │ └── TenantContextFilter.java │ └── resources │ │ ├── application.yml │ │ ├── db │ │ └── migration │ │ │ ├── default │ │ │ └── V1__Creative_table.sql │ │ │ └── tenant │ │ │ └── V1__Instrument_table.sql │ │ └── logback-spring.xml │ └── test │ └── java │ └── com │ └── thomasvitale │ └── instrumentservice │ ├── InstrumentServiceApplicationTests.java │ └── TestInstrumentServiceApplication.java └── platform ├── alloy └── config.alloy ├── grafana ├── README.md ├── dashboards │ ├── applications │ │ ├── jvm.json │ │ ├── polar-http.json │ │ ├── spring-boot-jdbc.json │ │ └── spring-boot.json │ ├── dashboards.yml │ └── platform │ │ └── prometheus-stats.json ├── datasources │ └── datasources.yml └── grafana.ini ├── keycloak └── realm-config.json ├── loki └── loki.yml ├── prometheus └── prometheus.yml └── tempo └── tempo.yml /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | insert_final_newline = true 8 | 9 | [*.gradle] 10 | indent_size = 4 11 | ij_continuation_indent_size = 8 12 | 13 | [*.java] 14 | indent_size = 4 15 | max_line_length = 120 16 | trim_trailing_whitespace=true 17 | ij_continuation_indent_size = 8 18 | ij_java_imports_layout = java.**, |, javax.**, |, jakarta.**, |, com.**, |, dev.**, |, io.**, |, org.**, |, *, |, $* 19 | ij_java_class_count_to_use_import_on_demand = 99 20 | ij_java_names_count_to_use_import_on_demand = 99 21 | 22 | [*.{yaml,yml}] 23 | indent_size = 2 24 | -------------------------------------------------------------------------------- /.github/workflows/commit-stage.yml: -------------------------------------------------------------------------------- 1 | name: Commit Stage 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | build: 12 | name: Build 13 | runs-on: ubuntu-22.04 14 | permissions: 15 | contents: read 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | project: [ 20 | chat-service, 21 | edge-service, 22 | instrument-service 23 | ] 24 | steps: 25 | - name: Check out source code 26 | uses: actions/checkout@v4 27 | 28 | - name: Set up Java 29 | uses: actions/setup-java@v4 30 | with: 31 | java-version: 21 32 | distribution: temurin 33 | 34 | - name: Compile and test 35 | run: | 36 | cd ${{ matrix.project }} 37 | chmod +x gradlew 38 | ./gradlew build 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled class file 2 | *.class 3 | 4 | # Log file 5 | *.log 6 | 7 | # BlueJ files 8 | *.ctxt 9 | 10 | # Mobile Tools for Java (J2ME) 11 | .mtj.tmp/ 12 | 13 | # Package Files # 14 | *.jar 15 | *.war 16 | *.nar 17 | *.ear 18 | *.zip 19 | *.tar.gz 20 | *.rar 21 | 22 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 23 | hs_err_pid* 24 | 25 | HELP.md 26 | .gradle 27 | build/ 28 | !gradle/wrapper/gradle-wrapper.jar 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### STS ### 33 | .apt_generated 34 | .classpath 35 | .factorypath 36 | .project 37 | .settings 38 | .springBeans 39 | .sts4-cache 40 | bin/ 41 | !**/src/main/**/bin/ 42 | !**/src/test/**/bin/ 43 | 44 | ### IntelliJ IDEA ### 45 | .idea 46 | *.iws 47 | *.iml 48 | *.ipr 49 | out/ 50 | !**/src/main/**/out/ 51 | !**/src/test/**/out/ 52 | 53 | ### NetBeans ### 54 | /nbproject/private/ 55 | /nbbuild/ 56 | /dist/ 57 | /nbdist/ 58 | /.nb-gradle/ 59 | 60 | ### VS Code ### 61 | .vscode/ 62 | 63 | 64 | ################################ 65 | ############ MAC ############### 66 | ################################ 67 | 68 | # General 69 | .DS_Store 70 | *.DS_Store 71 | **/.DS_Store 72 | .AppleDouble 73 | .LSOverride 74 | 75 | # Icon must end with two \r 76 | Icon 77 | 78 | # Thumbnails 79 | ._* 80 | 81 | # Files that might appear in the root of a volume 82 | .DocumentRevisions-V100 83 | .fseventsd 84 | .Spotlight-V100 85 | .TemporaryItems 86 | .Trashes 87 | .VolumeIcon.icns 88 | .com.apple.timemachine.donotpresent 89 | 90 | # Directories potentially created on remote AFP share 91 | .AppleDB 92 | .AppleDesktop 93 | Network Trash Folder 94 | Temporary Items 95 | .apdisk 96 | -------------------------------------------------------------------------------- /.sdkmanrc: -------------------------------------------------------------------------------- 1 | # Use sdkman to run "sdk env" to initialize with correct JDK version 2 | # Enable auto-env through the sdkman_auto_env config 3 | # See https://sdkman.io/usage#config 4 | # A summary is to add the following to ~/.sdkman/etc/config 5 | # sdkman_auto_env=true 6 | java=21.0.2-tem 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spring Boot Multitenancy 2 | 3 | ## Stack 4 | 5 | * Java 21 6 | * Spring Boot 3.2 7 | * Grafana OSS 8 | 9 | ## Usage 10 | 11 | You can use Docker Compose to run the necessary backing services for observability, authentication, and AI. 12 | 13 | From the project root folder, run Docker Compose. 14 | 15 | ```bash 16 | docker-compose up -d 17 | ``` 18 | 19 | The Instrument Service application can be run as follows to rely on Testcontainers to spin up a PostgreSQL database: 20 | 21 | ```bash 22 | ./gradlew bootTestRun 23 | ``` 24 | 25 | The Edge Service application can be run as follows: 26 | 27 | ```bash 28 | ./gradlew bootRun 29 | ``` 30 | 31 | The Chat Service application can be run using one of the two techniques described above. If you don't want to rely on Testcontainers, 32 | make sure you have [Ollama](https://ollama.ai/) installed and the Llama2 model available (`ollama run llama2`). 33 | 34 | Two tenants are configured: `dukes` and `beans`. Ensure you add the following configuration to your `hosts` file to resolve tenants from DNS names. 35 | 36 | ```bash 37 | 127.0.0.1 dukes.rock 38 | 127.0.0.1 beans.rock 39 | ``` 40 | 41 | Now open the browser window and navigate to `http://dukes.rock/instruments/`. You'll be redirected to the Keycloak authentication page. Log in with `isabelle/password`. The result will be the list of instruments from the Dukes rock band. 42 | 43 | Now open another browser window and navigate to `http://beans.rock/instruments/`. You'll be redirected to the Keycloak authentication page. Log in with `bjorn/password`. The result will be the list of instruments from the Beans rock band. 44 | -------------------------------------------------------------------------------- /chat-service/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | insert_final_newline = true 8 | 9 | [*.gradle] 10 | indent_size = 4 11 | ij_continuation_indent_size = 8 12 | 13 | [*.java] 14 | indent_size = 4 15 | max_line_length = 120 16 | trim_trailing_whitespace=true 17 | ij_continuation_indent_size = 8 18 | ij_java_imports_layout = java.**, |, javax.**, |, jakarta.**, |, com.**, |, dev.**, |, io.**, |, org.**, |, *, |, $* 19 | ij_java_class_count_to_use_import_on_demand = 99 20 | ij_java_names_count_to_use_import_on_demand = 99 21 | 22 | [*.{yaml,yml}] 23 | indent_size = 2 24 | -------------------------------------------------------------------------------- /chat-service/.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | -------------------------------------------------------------------------------- /chat-service/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java' 3 | id 'org.springframework.boot' version '3.2.4' 4 | id 'io.spring.dependency-management' version '1.1.4' 5 | id 'org.cyclonedx.bom' version '1.8.2' 6 | } 7 | 8 | group = 'com.thomasvitale' 9 | version = '0.0.1-SNAPSHOT' 10 | 11 | java { 12 | toolchain { 13 | languageVersion = JavaLanguageVersion.of(21) 14 | } 15 | } 16 | 17 | repositories { 18 | mavenCentral() 19 | maven { url 'https://repo.spring.io/milestone' } 20 | } 21 | 22 | dependencies { 23 | implementation 'org.springframework.boot:spring-boot-starter-actuator' 24 | implementation 'org.springframework.boot:spring-boot-starter-web' 25 | implementation 'io.micrometer:micrometer-tracing-bridge-otel' 26 | implementation 'io.opentelemetry:opentelemetry-exporter-otlp' 27 | 28 | implementation 'org.springframework.ai:spring-ai-ollama-spring-boot-starter:0.8.1' 29 | implementation 'org.springframework.ai:spring-ai-chroma-store-spring-boot-starter:0.8.1' 30 | 31 | runtimeOnly 'io.micrometer:micrometer-registry-prometheus' 32 | runtimeOnly 'com.github.loki4j:loki-logback-appender:1.5.1' 33 | 34 | testAndDevelopmentOnly 'org.springframework.boot:spring-boot-devtools' 35 | 36 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 37 | testImplementation 'org.springframework.boot:spring-boot-testcontainers' 38 | testImplementation 'org.testcontainers:junit-jupiter' 39 | } 40 | 41 | tasks.named('test') { 42 | useJUnitPlatform() 43 | } 44 | 45 | tasks.named('cyclonedxBom') { 46 | outputFormat = "json" 47 | projectType = "application" 48 | schemaVersion = "1.5" 49 | } 50 | -------------------------------------------------------------------------------- /chat-service/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasVitale/spring-boot-multitenancy/1f9caec0d6ec655f60f002676a4503bb962743e5/chat-service/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /chat-service/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /chat-service/gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /chat-service/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /chat-service/settings.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "org.gradle.toolchains.foojay-resolver-convention" version '0.8.0' 3 | } 4 | 5 | rootProject.name = 'chat-service' 6 | -------------------------------------------------------------------------------- /chat-service/src/main/java/com/thomasvitale/chatservice/ChatServiceApplication.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan; 6 | 7 | @SpringBootApplication 8 | @ConfigurationPropertiesScan 9 | public class ChatServiceApplication { 10 | 11 | public static void main(String[] args) { 12 | SpringApplication.run(ChatServiceApplication.class, args); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /chat-service/src/main/java/com/thomasvitale/chatservice/chat/DocumentChatController.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice.chat; 2 | 3 | import org.springframework.web.bind.annotation.PostMapping; 4 | import org.springframework.web.bind.annotation.RequestBody; 5 | import org.springframework.web.bind.annotation.RestController; 6 | 7 | @RestController 8 | class DocumentChatController { 9 | 10 | private final DocumentChatService documentChatService; 11 | 12 | DocumentChatController(DocumentChatService documentChatService) { 13 | this.documentChatService = documentChatService; 14 | } 15 | 16 | @PostMapping("/ai/doc/chat") 17 | String chatWithDocument(@RequestBody String input) { 18 | return documentChatService.chatWithDocument(input).getContent(); 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /chat-service/src/main/java/com/thomasvitale/chatservice/chat/DocumentChatService.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice.chat; 2 | 3 | import java.util.List; 4 | import java.util.Map; 5 | import java.util.stream.Collectors; 6 | 7 | import com.thomasvitale.chatservice.multitenancy.context.TenantContextHolder; 8 | 9 | import org.springframework.ai.chat.ChatClient; 10 | import org.springframework.ai.chat.messages.AssistantMessage; 11 | import org.springframework.ai.chat.messages.UserMessage; 12 | import org.springframework.ai.chat.prompt.Prompt; 13 | import org.springframework.ai.chat.prompt.SystemPromptTemplate; 14 | import org.springframework.ai.document.Document; 15 | import org.springframework.ai.vectorstore.SearchRequest; 16 | import org.springframework.ai.vectorstore.VectorStore; 17 | import org.springframework.stereotype.Service; 18 | 19 | @Service 20 | class DocumentChatService { 21 | 22 | private final ChatClient chatClient; 23 | private final VectorStore vectorStore; 24 | 25 | DocumentChatService(ChatClient chatClient, VectorStore vectorStore) { 26 | this.chatClient = chatClient; 27 | this.vectorStore = vectorStore; 28 | } 29 | 30 | AssistantMessage chatWithDocument(String message) { 31 | var systemPromptTemplate = new SystemPromptTemplate(""" 32 | You're assisting with questions about rock bands, their story, their members, and their music. 33 | 34 | Use the information from the DOCUMENTS section to provide accurate answers and assume no prior knowledge, 35 | but act as if you knew this information innately. If unsure, simply state that you don't know. 36 | 37 | DOCUMENTS: 38 | {documents} 39 | """); 40 | 41 | List similarDocuments = vectorStore.similaritySearch( 42 | SearchRequest 43 | .query(message) 44 | .withFilterExpression("tenant == '%s'".formatted(TenantContextHolder.getRequiredTenantIdentifier())) 45 | .withTopK(3)); 46 | String documents = similarDocuments.stream().map(Document::getContent).collect(Collectors.joining(System.lineSeparator())); 47 | 48 | Map model = Map.of("documents", documents); 49 | var systemMessage = systemPromptTemplate.createMessage(model); 50 | 51 | var userMessage = new UserMessage(message); 52 | var prompt = new Prompt(List.of(systemMessage, userMessage)); 53 | 54 | var chatResponse = chatClient.call(prompt); 55 | return chatResponse.getResult().getOutput(); 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /chat-service/src/main/java/com/thomasvitale/chatservice/chat/SimpleChatController.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice.chat; 2 | 3 | import org.springframework.ai.chat.ChatClient; 4 | import org.springframework.web.bind.annotation.PostMapping; 5 | import org.springframework.web.bind.annotation.RequestBody; 6 | import org.springframework.web.bind.annotation.RestController; 7 | 8 | @RestController 9 | public class SimpleChatController { 10 | 11 | private final ChatClient chatClient; 12 | 13 | public SimpleChatController(ChatClient chatClient) { 14 | this.chatClient = chatClient; 15 | } 16 | 17 | @PostMapping("/ai/simple/chat") 18 | String chat(@RequestBody String message) { 19 | return chatClient.call(message); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /chat-service/src/main/java/com/thomasvitale/chatservice/documents/DocumentInitializer.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice.documents; 2 | 3 | import java.nio.charset.Charset; 4 | import java.util.ArrayList; 5 | import java.util.List; 6 | 7 | import jakarta.annotation.PostConstruct; 8 | 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | import org.springframework.ai.document.Document; 12 | import org.springframework.ai.reader.TextReader; 13 | import org.springframework.ai.transformer.splitter.TokenTextSplitter; 14 | import org.springframework.ai.vectorstore.VectorStore; 15 | import org.springframework.beans.factory.annotation.Value; 16 | import org.springframework.core.io.Resource; 17 | import org.springframework.stereotype.Component; 18 | 19 | @Component 20 | public class DocumentInitializer { 21 | 22 | private static final Logger log = LoggerFactory.getLogger(DocumentInitializer.class); 23 | private final VectorStore vectorStore; 24 | 25 | @Value("classpath:documents/story1.md") 26 | Resource textFile1; 27 | 28 | @Value("classpath:documents/story2.txt") 29 | Resource textFile2; 30 | 31 | public DocumentInitializer(VectorStore vectorStore) { 32 | this.vectorStore = vectorStore; 33 | } 34 | 35 | @PostConstruct 36 | public void run() { 37 | List documents = new ArrayList<>(); 38 | 39 | log.info("Loading .md files as Documents (dukes)"); 40 | var textReader1 = new TextReader(textFile1); 41 | textReader1.getCustomMetadata().put("tenant", "dukes"); 42 | textReader1.setCharset(Charset.defaultCharset()); 43 | documents.addAll(textReader1.get()); 44 | 45 | log.info("Loading .txt files as Documents (beans)"); 46 | var textReader2 = new TextReader(textFile2); 47 | textReader2.getCustomMetadata().put("tenant", "beans"); 48 | textReader2.setCharset(Charset.defaultCharset()); 49 | documents.addAll(textReader2.get()); 50 | 51 | log.info("Splitting text into chunks"); 52 | var tokenizedDocuments = new TokenTextSplitter().apply(documents); 53 | 54 | log.info("Creating and storing Embeddings from Documents"); 55 | vectorStore.add(tokenizedDocuments); 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /chat-service/src/main/java/com/thomasvitale/chatservice/multitenancy/context/TenantContextHolder.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice.multitenancy.context; 2 | 3 | import com.thomasvitale.chatservice.multitenancy.exceptions.TenantNotFoundException; 4 | 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | import org.springframework.lang.Nullable; 8 | import org.springframework.util.Assert; 9 | import org.springframework.util.StringUtils; 10 | 11 | /** 12 | * A shared, thread-local store for the current tenant. 13 | */ 14 | public final class TenantContextHolder { 15 | 16 | private static final Logger log = LoggerFactory.getLogger(TenantContextHolder.class); 17 | 18 | private static final ThreadLocal tenantIdentifier = new ThreadLocal<>(); 19 | 20 | private TenantContextHolder() { 21 | } 22 | 23 | public static void setTenantIdentifier(String tenant) { 24 | Assert.hasText(tenant, "tenant cannot be empty"); 25 | log.trace("Setting current tenant to: {}", tenant); 26 | tenantIdentifier.set(tenant); 27 | } 28 | 29 | @Nullable 30 | public static String getTenantIdentifier() { 31 | return tenantIdentifier.get(); 32 | } 33 | 34 | public static String getRequiredTenantIdentifier() { 35 | var tenant = getTenantIdentifier(); 36 | if (!StringUtils.hasText(tenant)) { 37 | throw new TenantNotFoundException("No tenant found in the current context"); 38 | } 39 | return tenant; 40 | } 41 | 42 | public static void clear() { 43 | log.trace("Clearing current tenant"); 44 | tenantIdentifier.remove(); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /chat-service/src/main/java/com/thomasvitale/chatservice/multitenancy/context/resolvers/HttpHeaderTenantResolver.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice.multitenancy.context.resolvers; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | 5 | import org.springframework.lang.Nullable; 6 | import org.springframework.stereotype.Component; 7 | 8 | /** 9 | * Strategy used to resolve the current tenant from a header in an HTTP request. 10 | */ 11 | @Component 12 | public class HttpHeaderTenantResolver implements TenantResolver { 13 | 14 | private static final String TENANT_HEADER = "X-TenantId"; 15 | 16 | @Override 17 | @Nullable 18 | public String resolveTenantIdentifier(HttpServletRequest request) { 19 | return request.getHeader(TENANT_HEADER); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /chat-service/src/main/java/com/thomasvitale/chatservice/multitenancy/context/resolvers/TenantResolver.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice.multitenancy.context.resolvers; 2 | 3 | import org.springframework.lang.Nullable; 4 | 5 | /** 6 | * Strategy used to resolve the current tenant from a given source context. 7 | */ 8 | @FunctionalInterface 9 | public interface TenantResolver { 10 | 11 | /** 12 | * Resolves a tenant identifier from the given source. 13 | */ 14 | @Nullable 15 | String resolveTenantIdentifier(T source); 16 | 17 | } 18 | -------------------------------------------------------------------------------- /chat-service/src/main/java/com/thomasvitale/chatservice/multitenancy/exceptions/TenantNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice.multitenancy.exceptions; 2 | 3 | /** 4 | * Thrown when no tenant information is found in a given context. 5 | */ 6 | public class TenantNotFoundException extends IllegalStateException { 7 | 8 | public TenantNotFoundException() { 9 | super("No tenant found in the current context"); 10 | } 11 | 12 | public TenantNotFoundException(String message) { 13 | super(message); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /chat-service/src/main/java/com/thomasvitale/chatservice/multitenancy/exceptions/TenantResolutionException.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice.multitenancy.exceptions; 2 | 3 | /** 4 | * Thrown when an error occurred during the tenant resolution process. 5 | */ 6 | public class TenantResolutionException extends IllegalStateException { 7 | 8 | public TenantResolutionException() { 9 | super("Error when trying to resolve the current tenant"); 10 | } 11 | 12 | public TenantResolutionException(String message) { 13 | super(message); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /chat-service/src/main/java/com/thomasvitale/chatservice/multitenancy/tenantdetails/PropertiesTenantDetailsService.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice.multitenancy.tenantdetails; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.stereotype.Service; 6 | 7 | @Service 8 | public class PropertiesTenantDetailsService implements TenantDetailsService { 9 | 10 | private final TenantDetailsProperties tenantDetailsProperties; 11 | 12 | public PropertiesTenantDetailsService(TenantDetailsProperties tenantDetailsProperties) { 13 | this.tenantDetailsProperties = tenantDetailsProperties; 14 | } 15 | 16 | @Override 17 | public List loadAllTenants() { 18 | return tenantDetailsProperties.tenants(); 19 | } 20 | 21 | @Override 22 | public TenantDetails loadTenantByIdentifier(String identifier) { 23 | return tenantDetailsProperties.tenants().stream() 24 | .filter(TenantDetails::enabled) 25 | .filter(tenantDetails -> identifier.equals(tenantDetails.identifier())) 26 | .findFirst().orElse(null); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /chat-service/src/main/java/com/thomasvitale/chatservice/multitenancy/tenantdetails/TenantDetails.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice.multitenancy.tenantdetails; 2 | 3 | /** 4 | * Provides core tenant information. 5 | */ 6 | public record TenantDetails( 7 | String identifier, 8 | boolean enabled 9 | ) {} 10 | -------------------------------------------------------------------------------- /chat-service/src/main/java/com/thomasvitale/chatservice/multitenancy/tenantdetails/TenantDetailsProperties.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice.multitenancy.tenantdetails; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.boot.context.properties.ConfigurationProperties; 6 | 7 | @ConfigurationProperties(prefix = "multitenancy") 8 | public record TenantDetailsProperties(List tenants) { } 9 | -------------------------------------------------------------------------------- /chat-service/src/main/java/com/thomasvitale/chatservice/multitenancy/tenantdetails/TenantDetailsService.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice.multitenancy.tenantdetails; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.lang.Nullable; 6 | 7 | /** 8 | * Core interface which loads tenant-specific data. 9 | * It is used throughout the framework as a tenant DAO. 10 | */ 11 | public interface TenantDetailsService { 12 | 13 | List loadAllTenants(); 14 | 15 | @Nullable 16 | TenantDetails loadTenantByIdentifier(String identifier); 17 | 18 | } 19 | -------------------------------------------------------------------------------- /chat-service/src/main/java/com/thomasvitale/chatservice/multitenancy/web/TenantContextFilter.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice.multitenancy.web; 2 | 3 | import com.thomasvitale.chatservice.multitenancy.context.TenantContextHolder; 4 | import com.thomasvitale.chatservice.multitenancy.context.resolvers.HttpHeaderTenantResolver; 5 | 6 | import com.thomasvitale.chatservice.multitenancy.exceptions.TenantNotFoundException; 7 | import com.thomasvitale.chatservice.multitenancy.exceptions.TenantResolutionException; 8 | import com.thomasvitale.chatservice.multitenancy.tenantdetails.TenantDetailsService; 9 | 10 | import io.micrometer.common.KeyValue; 11 | 12 | import jakarta.servlet.FilterChain; 13 | import jakarta.servlet.ServletException; 14 | import jakarta.servlet.http.HttpServletRequest; 15 | import jakarta.servlet.http.HttpServletResponse; 16 | 17 | import org.slf4j.MDC; 18 | import org.springframework.stereotype.Component; 19 | import org.springframework.util.StringUtils; 20 | import org.springframework.web.filter.OncePerRequestFilter; 21 | import org.springframework.web.filter.ServerHttpObservationFilter; 22 | 23 | import java.io.IOException; 24 | 25 | /** 26 | * Establish a tenant context from an HTTP request, if tenant information is available. 27 | */ 28 | @Component 29 | public class TenantContextFilter extends OncePerRequestFilter { 30 | 31 | private final HttpHeaderTenantResolver httpRequestTenantResolver; 32 | private final TenantDetailsService tenantDetailsService; 33 | 34 | public TenantContextFilter(HttpHeaderTenantResolver httpHeaderTenantResolver, TenantDetailsService tenantDetailsService) { 35 | this.httpRequestTenantResolver = httpHeaderTenantResolver; 36 | this.tenantDetailsService = tenantDetailsService; 37 | } 38 | 39 | @Override 40 | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { 41 | var tenantIdentifier = httpRequestTenantResolver.resolveTenantIdentifier(request); 42 | 43 | if (StringUtils.hasText(tenantIdentifier)) { 44 | if (!isTenantValid(tenantIdentifier)) { 45 | throw new TenantNotFoundException(); 46 | } 47 | TenantContextHolder.setTenantIdentifier(tenantIdentifier); 48 | configureLogs(tenantIdentifier); 49 | configureTraces(tenantIdentifier, request); 50 | } else { 51 | throw new TenantResolutionException("A tenant must be specified for requests to %s".formatted(request.getRequestURI())); 52 | } 53 | 54 | try { 55 | filterChain.doFilter(request, response); 56 | } finally { 57 | clear(); 58 | } 59 | } 60 | 61 | @Override 62 | protected boolean shouldNotFilter(HttpServletRequest request) { 63 | return request.getRequestURI().startsWith("/actuator"); 64 | } 65 | 66 | private boolean isTenantValid(String tenantIdentifier) { 67 | var tenantDetails = tenantDetailsService.loadTenantByIdentifier(tenantIdentifier); 68 | return tenantDetails.enabled(); 69 | } 70 | 71 | private void configureLogs(String tenantId) { 72 | MDC.put("tenantId", tenantId); 73 | } 74 | 75 | private void configureTraces(String tenantId, HttpServletRequest request) { 76 | ServerHttpObservationFilter.findObservationContext(request).ifPresent(context -> 77 | context.addHighCardinalityKeyValue(KeyValue.of("tenant.id", tenantId))); 78 | } 79 | 80 | private void clear() { 81 | MDC.remove("tenantId"); 82 | TenantContextHolder.clear(); 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /chat-service/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8282 3 | 4 | spring: 5 | application: 6 | name: chat-service 7 | ai: 8 | ollama: 9 | chat: 10 | model: llama2 11 | embedding: 12 | model: llama2 13 | threads: 14 | virtual: 15 | enabled: true 16 | 17 | logging: 18 | pattern: 19 | correlation: '[%X{traceId:-}-%X{spanId:-}] [%X{tenantId:-}] ' 20 | 21 | management: 22 | endpoints: 23 | web: 24 | exposure: 25 | include: "*" 26 | metrics: 27 | tags: 28 | application: ${spring.application.name} 29 | distribution: 30 | percentiles-histogram: 31 | all: true 32 | http.server.requests: true 33 | opentelemetry: 34 | resource-attributes: 35 | application: ${spring.application.name} 36 | "service.name": ${spring.application.name} 37 | otlp: 38 | tracing: 39 | endpoint: http://localhost:4318/v1/traces 40 | tracing: 41 | sampling: 42 | probability: 1.0 43 | prometheus: 44 | metrics: 45 | export: 46 | step: 5s 47 | endpoint: 48 | health: 49 | probes: 50 | enabled: true 51 | show-details: always 52 | show-components: always 53 | 54 | multitenancy: 55 | tenants: 56 | - identifier: dukes 57 | enabled: true 58 | - identifier: beans 59 | enabled: true 60 | - identifier: trixie 61 | enabled: false 62 | -------------------------------------------------------------------------------- /chat-service/src/main/resources/documents/story1.md: -------------------------------------------------------------------------------- 1 | # The Story of The Dukes 2 | 3 | It was a chilly autumn night when The Dukes took to the stage at the infamous "Rockin' Roasters" venue in downtown Seattle. 4 | The air was alive with anticipation as the crowd eagerly awaited the debut performance of this talented new band. And what 5 | a lineup they had - three guitarists, a bass player, a pianist, a singer, and a drummer, each one chosen for their exceptional 6 | skill and passion for music. 7 | 8 | Leading the charge was guitarist "Dark Roast" Dave, whose rich, full-bodied riffs set the tone for the evening. On his right 9 | was "French Vanilla" Fiona, delivering crisp, clean guitar lines with ease. To their left was "Latte" Larry, whose intricate 10 | fingerpicking added a delicate touch to the band's sound. 11 | 12 | Bassist "Espresso" Esther provided the heartbeat of The Dukes, her driving rhythms pulsing through the speakers and keeping 13 | the crowd moving. Next to her was "Cappuccino" Clara, whose soaring keyboard melodies added a layer of depth and complexity 14 | to the music. 15 | 16 | Lead singer "Mocha" Mike brought it all together with his powerful, soulful voice, weaving in and out of the instruments 17 | with ease. And behind it all was "Chai" Charlie, the drummer, keeping the whole thing together with a steady beat that never 18 | faltered. 19 | 20 | As they launched into their debut single, "The Roasted Riff," the crowd erupted in cheers and applause, captivated by the 21 | sheer talent and energy of The Dukes. It was clear that this was a band to watch, one that would leave its mark on the music 22 | world. And as they continued to play, it became increasingly evident that their sound was something truly special - a perfect 23 | blend of coffee-inspired flavors and rock 'n' roll spirit. 24 | -------------------------------------------------------------------------------- /chat-service/src/main/resources/documents/story2.txt: -------------------------------------------------------------------------------- 1 | The Story of The Beans 2 | 3 | It was a crisp autumn evening when "The Beans" took to the stage at the popular "Tea House" venue in New York City. The 4 | crowd was buzzing with excitement as they waited for the band to start their set, and as soon as they did, the energy in the 5 | room shifted into high gear. 6 | 7 | Leading the charge was singer "Earl Grey" Emily, whose rich, smooth vocals soared above the rest of the band, leaving a 8 | lasting impression on everyone in attendance. On her right was "English Breakfast" Bethany, the bass player, who provided 9 | the foundation for the band's groovy vibes. 10 | 11 | Next to Bethany was "Chamomile" Charlie, the piano player, whose intricate melodies added a delicate touch to the music. To 12 | their left was "Mint" Matt, the guitarist, who delivered crisp, clean riffs with ease and style. 13 | 14 | Drummer "Lemon Ginger" Laura brought it all together with her infectious beats, keeping the rhythm tight and the crowd 15 | moving. Joining her behind the drums was "Peppermint" Phoebe, the percussionist, whose diverse skills added a new dimension 16 | to The Beans' sound. 17 | 18 | Rounding out the band were violinist "Licorice" Lucy and cellist "Cinnamon" Carly, who together created a lush, ethereal 19 | backdrop for the rest of the band to weave their magic around. Their contributions were subtle but crucial, adding a layer 20 | of depth and complexity that elevated The Beans' music to new heights. 21 | 22 | As they performed their catchy, upbeat tunes, it was clear that this talented group of musicians had something special going 23 | on. They moved as one, their instruments blending together in perfect harmony, creating a sound that was both unique and 24 | unforgettable. And with each passing moment, the crowd grew more and more captivated by "The Beans" - a band that was truly 25 | brewing up something extraordinary. 26 | -------------------------------------------------------------------------------- /chat-service/src/test/java/com/thomasvitale/chatservice/ChatServiceApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice; 2 | 3 | import org.junit.jupiter.api.Disabled; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | 7 | @SpringBootTest 8 | @Disabled 9 | class ChatServiceApplicationTests { 10 | 11 | @Test 12 | void contextLoads() { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /chat-service/src/test/java/com/thomasvitale/chatservice/TestChatServiceApplication.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.chatservice; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.devtools.restart.RestartScope; 5 | import org.springframework.boot.test.context.TestConfiguration; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Scope; 8 | import org.springframework.test.context.DynamicPropertyRegistry; 9 | import org.testcontainers.containers.GenericContainer; 10 | 11 | @TestConfiguration(proxyBeanMethods = false) 12 | public class TestChatServiceApplication { 13 | 14 | @Bean 15 | @RestartScope 16 | @Scope("singleton") // needed because of https://github.com/spring-projects/spring-boot/issues/35786 17 | GenericContainer ollama(DynamicPropertyRegistry properties) { 18 | var ollama = new GenericContainer<>("ghcr.io/thomasvitale/ollama-llama2") 19 | .withExposedPorts(11434); 20 | properties.add("spring.ai.ollama.base-url", 21 | () -> "http://%s:%s".formatted(ollama.getHost(), ollama.getMappedPort(11434))); 22 | return ollama; 23 | } 24 | 25 | @Bean 26 | @RestartScope 27 | @Scope("singleton") // needed because of https://github.com/spring-projects/spring-boot/issues/35786 28 | GenericContainer chroma(DynamicPropertyRegistry properties) { 29 | var chroma = new GenericContainer<>("ghcr.io/chroma-core/chroma:0.4.22") 30 | .withExposedPorts(8000); 31 | properties.add("spring.ai.vectorstore.chroma.client.host", 32 | () -> "http://%s".formatted(chroma.getHost())); 33 | properties.add("spring.ai.vectorstore.chroma.client.port", 34 | () -> chroma.getMappedPort(8000)); 35 | return chroma; 36 | } 37 | 38 | public static void main(String[] args) { 39 | SpringApplication.from(ChatServiceApplication::main).with(TestChatServiceApplication.class).run(args); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | # Applications 4 | 5 | keycloak: 6 | image: quay.io/keycloak/keycloak:24.0 7 | container_name: keycloak 8 | command: start-dev --import-realm --metrics-enabled=true 9 | depends_on: 10 | - alloy 11 | volumes: 12 | - ./platform/keycloak:/opt/keycloak/data/import 13 | environment: 14 | - KEYCLOAK_ADMIN=user 15 | - KEYCLOAK_ADMIN_PASSWORD=password 16 | ports: 17 | - 8080:8080 18 | 19 | # Data 20 | 21 | chroma: 22 | image: ghcr.io/chroma-core/chroma:0.4.24 23 | container_name: chroma 24 | depends_on: 25 | - alloy 26 | ports: 27 | - 8000:8000 28 | environment: 29 | - CHROMA_OTEL_COLLECTION_ENDPOINT=http://grafana-agent:4317 30 | - CHROMA_OTEL_SERVICE_NAME=chroma 31 | - CHROMA_OTEL_GRANULARITY=all 32 | - ANONYMIZED_TELEMETRY=False 33 | 34 | postgres: 35 | image: docker.io/library/postgres:16.2 36 | container_name: postgres 37 | depends_on: 38 | - alloy 39 | environment: 40 | - POSTGRES_USER=user 41 | - POSTGRES_PASSWORD=password 42 | - POSTGRES_DB=grafana 43 | volumes: 44 | - postgres-data:/var/lib/postgresql/data 45 | restart: unless-stopped 46 | 47 | # Observability 48 | 49 | grafana: 50 | image: docker.io/grafana/grafana-oss:10.4.2 51 | container_name: grafana 52 | depends_on: 53 | - loki 54 | - prometheus 55 | - tempo 56 | ports: 57 | - "3000:3000" 58 | hostname: ${HOST_NAME:-localhost} 59 | environment: 60 | - GF_AUTH_ANONYMOUS_ENABLED=true 61 | - GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer 62 | - GF_DATABASE_USER=user 63 | - GF_DATABASE_PASSWORD=password 64 | - GF_SECURITY_ADMIN_USER=user 65 | - GF_SECURITY_ADMIN_PASSWORD=password 66 | volumes: 67 | - ./platform/grafana/datasources:/etc/grafana/provisioning/datasources:ro 68 | - ./platform/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro 69 | - ./platform/grafana/grafana.ini:/etc/grafana/grafana.ini:ro 70 | - grafana-data:/var/lib/grafana 71 | restart: unless-stopped 72 | 73 | loki: 74 | image: docker.io/grafana/loki:2.9.7 75 | container_name: loki 76 | command: -config.file=/etc/config/loki.yml 77 | ports: 78 | - "3100:3100" 79 | hostname: ${HOST_NAME:-localhost} 80 | volumes: 81 | - ./platform/loki/loki.yml:/etc/config/loki.yml 82 | restart: unless-stopped 83 | 84 | prometheus: 85 | image: quay.io/prometheus/prometheus:v2.51.2 86 | container_name: prometheus 87 | command: 88 | - "--config.file=/etc/config/prometheus.yml" 89 | - "--enable-feature=otlp-write-receiver" 90 | - "--enable-feature=exemplar-storage" 91 | - "--web.enable-remote-write-receiver" 92 | ports: 93 | - "9090" 94 | hostname: ${HOST_NAME:-localhost} 95 | volumes: 96 | - ./platform/prometheus/prometheus.yml:/etc/config/prometheus.yml 97 | restart: unless-stopped 98 | 99 | tempo: 100 | image: docker.io/grafana/tempo:2.4.1 101 | container_name: tempo 102 | command: -config.file /etc/tempo-config.yml 103 | ports: 104 | - "3110" # Tempo 105 | - "4317" # OTLP gRPC 106 | - "4318" # OTLP HTTP 107 | - "9411" # Zipkin 108 | hostname: ${HOST_NAME:-localhost} 109 | volumes: 110 | - ./platform/tempo/tempo.yml:/etc/tempo-config.yml 111 | restart: unless-stopped 112 | 113 | alloy: 114 | image: docker.io/grafana/alloy:v1.0.0 115 | container_name: alloy 116 | command: 117 | - "run" 118 | - "--server.http.listen-addr=0.0.0.0:12345" 119 | - "--storage.path=/data-alloy" 120 | - "--disable-reporting" 121 | - "/etc/alloy/config.alloy" 122 | depends_on: 123 | - loki 124 | - prometheus 125 | - tempo 126 | hostname: ${HOST_NAME:-localhost} 127 | environment: 128 | - ENVIRONMENT=dev 129 | - LOKI_URL=http://loki:3100/loki/api/v1/push 130 | - PROMETHEUS_URL=http://prometheus:9090/api/v1/write 131 | - TEMPO_URL=http://tempo:4317 132 | - POSTGRES_USER=user 133 | - POSTGRES_PASSWORD=password 134 | ports: 135 | - "12345:12345" 136 | - "4317:4317" 137 | - "4318:4318" 138 | volumes: 139 | - ./platform/alloy/config.alloy:/etc/alloy/config.alloy 140 | - /var/run/docker.sock:/var/run/docker.sock 141 | - alloy-data:/data-alloy 142 | restart: unless-stopped 143 | 144 | volumes: 145 | alloy-data: { } 146 | grafana-data: { } 147 | postgres-data: { } 148 | -------------------------------------------------------------------------------- /edge-service/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | insert_final_newline = true 8 | 9 | [*.gradle] 10 | indent_size = 4 11 | ij_continuation_indent_size = 8 12 | 13 | [*.java] 14 | indent_size = 4 15 | max_line_length = 120 16 | trim_trailing_whitespace=true 17 | ij_continuation_indent_size = 8 18 | ij_java_imports_layout = java.**, |, javax.**, |, jakarta.**, |, com.**, |, dev.**, |, io.**, |, org.**, |, *, |, $* 19 | ij_java_class_count_to_use_import_on_demand = 99 20 | ij_java_names_count_to_use_import_on_demand = 99 21 | 22 | [*.{yaml,yml}] 23 | indent_size = 2 24 | -------------------------------------------------------------------------------- /edge-service/.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | -------------------------------------------------------------------------------- /edge-service/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java' 3 | id 'org.springframework.boot' version '3.2.4' 4 | id 'io.spring.dependency-management' version '1.1.4' 5 | id 'org.cyclonedx.bom' version '1.8.2' 6 | } 7 | 8 | group = 'com.thomasvitale' 9 | version = '0.0.1-SNAPSHOT' 10 | 11 | java { 12 | toolchain { 13 | languageVersion = JavaLanguageVersion.of(21) 14 | } 15 | } 16 | 17 | configurations { 18 | compileOnly { 19 | extendsFrom annotationProcessor 20 | } 21 | } 22 | 23 | repositories { 24 | mavenCentral() 25 | } 26 | 27 | ext { 28 | set('springCloudVersion', "2023.0.1") 29 | } 30 | 31 | dependencies { 32 | implementation 'org.springframework.boot:spring-boot-starter-actuator' 33 | implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' 34 | implementation 'org.springframework.boot:spring-boot-starter-webflux' 35 | implementation 'org.springframework.cloud:spring-cloud-starter-gateway' 36 | implementation 'io.micrometer:micrometer-tracing-bridge-otel' 37 | implementation 'io.opentelemetry:opentelemetry-exporter-otlp' 38 | 39 | runtimeOnly 'io.micrometer:micrometer-registry-prometheus' 40 | runtimeOnly 'com.github.loki4j:loki-logback-appender:1.5.1' 41 | 42 | // Only on Apple Silicon. Why it's necessary: https://github.com/netty/netty/issues/11020 43 | runtimeOnly 'io.netty:netty-resolver-dns-native-macos:4.1.105.Final:osx-aarch_64' 44 | 45 | annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' 46 | 47 | testAndDevelopmentOnly 'org.springframework.boot:spring-boot-devtools' 48 | 49 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 50 | testImplementation 'io.projectreactor:reactor-test' 51 | } 52 | 53 | dependencyManagement { 54 | imports { 55 | mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" 56 | } 57 | } 58 | 59 | tasks.named('test') { 60 | useJUnitPlatform() 61 | } 62 | 63 | tasks.named('cyclonedxBom') { 64 | outputFormat = "json" 65 | projectType = "application" 66 | schemaVersion = "1.5" 67 | } 68 | -------------------------------------------------------------------------------- /edge-service/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasVitale/spring-boot-multitenancy/1f9caec0d6ec655f60f002676a4503bb962743e5/edge-service/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /edge-service/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /edge-service/gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /edge-service/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /edge-service/settings.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "org.gradle.toolchains.foojay-resolver-convention" version '0.8.0' 3 | } 4 | 5 | rootProject.name = 'edge-service' 6 | -------------------------------------------------------------------------------- /edge-service/src/main/java/com/thomasvitale/edgeservice/EdgeServiceApplication.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.edgeservice; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan; 6 | 7 | import reactor.core.publisher.Hooks; 8 | 9 | @SpringBootApplication 10 | @ConfigurationPropertiesScan 11 | public class EdgeServiceApplication { 12 | 13 | public static void main(String[] args) { 14 | Hooks.enableAutomaticContextPropagation(); 15 | SpringApplication.run(EdgeServiceApplication.class, args); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /edge-service/src/main/java/com/thomasvitale/edgeservice/config/SecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.edgeservice.config; 2 | 3 | import com.thomasvitale.edgeservice.multitenancy.security.TenantAuthenticationEntryPoint; 4 | import com.thomasvitale.edgeservice.multitenancy.security.TenantClientRegistrationRepository; 5 | 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.security.config.web.server.ServerHttpSecurity; 9 | import org.springframework.security.web.server.SecurityWebFilterChain; 10 | 11 | @Configuration(proxyBeanMethods = false) 12 | public class SecurityConfig { 13 | 14 | @Bean 15 | SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http, TenantClientRegistrationRepository clientRegistrationRepository) { 16 | return http 17 | .authorizeExchange(exchange -> exchange 18 | .pathMatchers("/actuator/**", "/tenant-login/**").permitAll() 19 | .anyExchange().authenticated()) 20 | .oauth2Login(oauth2 -> oauth2 21 | .clientRegistrationRepository(clientRegistrationRepository)) 22 | .exceptionHandling(exception -> 23 | exception.authenticationEntryPoint(new TenantAuthenticationEntryPoint())) 24 | .build(); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /edge-service/src/main/java/com/thomasvitale/edgeservice/multitenancy/exceptions/TenantNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.edgeservice.multitenancy.exceptions; 2 | 3 | /** 4 | * Thrown when no tenant information is found in a given context. 5 | */ 6 | public class TenantNotFoundException extends IllegalStateException { 7 | 8 | public TenantNotFoundException() { 9 | super("No tenant found in the current context"); 10 | } 11 | 12 | public TenantNotFoundException(String message) { 13 | super(message); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /edge-service/src/main/java/com/thomasvitale/edgeservice/multitenancy/exceptions/TenantResolutionException.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.edgeservice.multitenancy.exceptions; 2 | 3 | /** 4 | * Thrown when an error occurred during the tenant resolution process. 5 | */ 6 | public class TenantResolutionException extends IllegalStateException { 7 | 8 | public TenantResolutionException() { 9 | super("Error when trying to resolve the current tenant"); 10 | } 11 | 12 | public TenantResolutionException(String message) { 13 | super(message); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /edge-service/src/main/java/com/thomasvitale/edgeservice/multitenancy/gateway/TenantGatewayFilterFactory.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.edgeservice.multitenancy.gateway; 2 | 3 | import io.micrometer.common.KeyValue; 4 | 5 | import org.springframework.cloud.gateway.filter.GatewayFilter; 6 | import org.springframework.cloud.gateway.filter.GatewayFilterChain; 7 | import org.springframework.cloud.gateway.filter.factory.AbstractNameValueGatewayFilterFactory; 8 | import org.springframework.cloud.gateway.support.ServerWebExchangeUtils; 9 | import org.springframework.http.server.reactive.ServerHttpRequest; 10 | import org.springframework.stereotype.Component; 11 | import org.springframework.web.filter.reactive.ServerHttpObservationFilter; 12 | import org.springframework.web.server.ServerWebExchange; 13 | 14 | import reactor.core.publisher.Mono; 15 | 16 | import static org.springframework.cloud.gateway.support.GatewayToStringStyler.filterToStringCreator; 17 | 18 | /** 19 | * Custom filter to extend the AddRequestHeader built-in filter so to 20 | * also include tenant information into the ObservabilityContext. 21 | */ 22 | @Component 23 | public class TenantGatewayFilterFactory extends AbstractNameValueGatewayFilterFactory { 24 | 25 | @Override 26 | public GatewayFilter apply(NameValueConfig config) { 27 | return new GatewayFilter() { 28 | @Override 29 | public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { 30 | String tenantId = ServerWebExchangeUtils.expand(exchange, config.getValue()); 31 | ServerHttpRequest request = addTenantToRequest(exchange, tenantId, config); 32 | addTenantToObservation(tenantId, exchange); 33 | return chain.filter(exchange.mutate().request(request).build()); 34 | } 35 | 36 | @Override 37 | public String toString() { 38 | return filterToStringCreator(TenantGatewayFilterFactory.this) 39 | .append(config.getName(), config.getValue()).toString(); 40 | } 41 | }; 42 | } 43 | 44 | private ServerHttpRequest addTenantToRequest(ServerWebExchange exchange, String tenantId, NameValueConfig config) { 45 | var tenantHeader = config.getName(); 46 | return exchange.getRequest().mutate() 47 | .headers(httpHeaders -> httpHeaders.add(tenantHeader, tenantId)) 48 | .build(); 49 | } 50 | 51 | private void addTenantToObservation(String tenantId, ServerWebExchange exchange) { 52 | ServerHttpObservationFilter.findObservationContext(exchange).ifPresent(context -> 53 | context.addHighCardinalityKeyValue(KeyValue.of("tenant.id", tenantId))); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /edge-service/src/main/java/com/thomasvitale/edgeservice/multitenancy/security/TenantAuthenticationEntryPoint.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.edgeservice.multitenancy.security; 2 | 3 | import java.net.URI; 4 | 5 | import org.springframework.security.core.AuthenticationException; 6 | import org.springframework.security.web.server.DefaultServerRedirectStrategy; 7 | import org.springframework.security.web.server.ServerAuthenticationEntryPoint; 8 | import org.springframework.security.web.server.ServerRedirectStrategy; 9 | import org.springframework.security.web.server.savedrequest.ServerRequestCache; 10 | import org.springframework.security.web.server.savedrequest.WebSessionServerRequestCache; 11 | import org.springframework.stereotype.Component; 12 | import org.springframework.util.Assert; 13 | import org.springframework.web.server.ServerWebExchange; 14 | 15 | import reactor.core.publisher.Mono; 16 | 17 | @Component 18 | public class TenantAuthenticationEntryPoint implements ServerAuthenticationEntryPoint { 19 | 20 | private ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); 21 | 22 | private ServerRequestCache requestCache = new WebSessionServerRequestCache(); 23 | 24 | @Override 25 | public Mono commence(ServerWebExchange exchange, AuthenticationException ex) { 26 | var baseLoginUri = "/oauth2/authorization/"; 27 | var tenantId = exchange.getRequest().getURI().getHost().split("\\.")[0]; 28 | var tenantLoginLocation = URI.create(baseLoginUri + tenantId); 29 | return this.requestCache.saveRequest(exchange) 30 | .then(this.redirectStrategy.sendRedirect(exchange, tenantLoginLocation)); 31 | } 32 | 33 | public void setRequestCache(ServerRequestCache requestCache) { 34 | Assert.notNull(requestCache, "requestCache cannot be null"); 35 | this.requestCache = requestCache; 36 | } 37 | 38 | public void setRedirectStrategy(ServerRedirectStrategy redirectStrategy) { 39 | Assert.notNull(redirectStrategy, "redirectStrategy cannot be null"); 40 | this.redirectStrategy = redirectStrategy; 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /edge-service/src/main/java/com/thomasvitale/edgeservice/multitenancy/security/TenantClientRegistrationRepository.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.edgeservice.multitenancy.security; 2 | 3 | import java.util.Map; 4 | import java.util.concurrent.ConcurrentHashMap; 5 | 6 | import com.thomasvitale.edgeservice.multitenancy.exceptions.TenantResolutionException; 7 | import com.thomasvitale.edgeservice.multitenancy.tenantdetails.TenantDetailsService; 8 | 9 | import org.springframework.security.oauth2.client.registration.ClientRegistration; 10 | import org.springframework.security.oauth2.client.registration.ClientRegistrations; 11 | import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; 12 | import org.springframework.stereotype.Component; 13 | 14 | import reactor.core.publisher.Mono; 15 | 16 | @Component 17 | public class TenantClientRegistrationRepository implements ReactiveClientRegistrationRepository { 18 | 19 | private static final Map> clientRegistrations = new ConcurrentHashMap<>(); 20 | 21 | private final TenantDetailsService tenantDetailsService; 22 | 23 | public TenantClientRegistrationRepository(TenantDetailsService tenantDetailsService) { 24 | this.tenantDetailsService = tenantDetailsService; 25 | } 26 | 27 | @Override 28 | public Mono findByRegistrationId(String registrationId) { 29 | return clientRegistrations.computeIfAbsent(registrationId, this::buildClientRegistration); 30 | } 31 | 32 | private Mono buildClientRegistration(String registrationId) { 33 | var tenantDetails = tenantDetailsService.loadTenantByIdentifier(registrationId); 34 | if (tenantDetails == null) { 35 | throw new TenantResolutionException("A valid tenant must be specified for authentication requests"); 36 | } 37 | return Mono.just(ClientRegistrations.fromOidcIssuerLocation(tenantDetails.issuer()) 38 | .registrationId(registrationId) 39 | .clientId(tenantDetails.clientId()) 40 | .clientSecret(tenantDetails.clientSecret()) 41 | .redirectUri("{baseUrl}/login/oauth2/code/" + registrationId) 42 | .scope("openid") 43 | .build()); 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /edge-service/src/main/java/com/thomasvitale/edgeservice/multitenancy/tenantdetails/PropertiesTenantDetailsService.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.edgeservice.multitenancy.tenantdetails; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.stereotype.Service; 6 | 7 | @Service 8 | public class PropertiesTenantDetailsService implements TenantDetailsService { 9 | 10 | private final TenantDetailsProperties tenantDetailsProperties; 11 | 12 | public PropertiesTenantDetailsService(TenantDetailsProperties tenantDetailsProperties) { 13 | this.tenantDetailsProperties = tenantDetailsProperties; 14 | } 15 | 16 | @Override 17 | public List loadAllTenants() { 18 | return tenantDetailsProperties.tenants(); 19 | } 20 | 21 | @Override 22 | public TenantDetails loadTenantByIdentifier(String identifier) { 23 | return tenantDetailsProperties.tenants().stream() 24 | .filter(TenantDetails::enabled) 25 | .filter(tenantDetails -> identifier.equals(tenantDetails.identifier())) 26 | .findFirst().orElse(null); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /edge-service/src/main/java/com/thomasvitale/edgeservice/multitenancy/tenantdetails/TenantDetails.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.edgeservice.multitenancy.tenantdetails; 2 | 3 | /** 4 | * Provides core tenant information. 5 | */ 6 | public record TenantDetails( 7 | String identifier, 8 | boolean enabled, 9 | String clientId, 10 | String clientSecret, 11 | String issuer 12 | ) {} 13 | -------------------------------------------------------------------------------- /edge-service/src/main/java/com/thomasvitale/edgeservice/multitenancy/tenantdetails/TenantDetailsProperties.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.edgeservice.multitenancy.tenantdetails; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.boot.context.properties.ConfigurationProperties; 6 | 7 | @ConfigurationProperties(prefix = "multitenancy") 8 | public record TenantDetailsProperties(List tenants) { } 9 | -------------------------------------------------------------------------------- /edge-service/src/main/java/com/thomasvitale/edgeservice/multitenancy/tenantdetails/TenantDetailsService.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.edgeservice.multitenancy.tenantdetails; 2 | 3 | import java.util.List; 4 | 5 | /** 6 | * Core interface which loads tenant-specific data. 7 | * It is used throughout the framework as a tenant DAO. 8 | */ 9 | public interface TenantDetailsService { 10 | 11 | List loadAllTenants(); 12 | 13 | TenantDetails loadTenantByIdentifier(String identifier); 14 | 15 | } 16 | -------------------------------------------------------------------------------- /edge-service/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 80 3 | 4 | spring: 5 | application: 6 | name: edge-service 7 | 8 | cloud: 9 | gateway: 10 | default-filters: 11 | - SaveSession 12 | - TokenRelay 13 | routes: 14 | - id: instrument-route 15 | uri: http://localhost:8181 16 | predicates: 17 | - Host={tenant}.rock 18 | - Path=/instruments/** 19 | filters: 20 | - AddRequestHeader=X-TenantId,{tenant} 21 | 22 | logging: 23 | pattern: 24 | correlation: '[%X{traceId:-}-%X{spanId:-}] [%X{tenantId:-}] ' 25 | 26 | management: 27 | endpoints: 28 | web: 29 | exposure: 30 | include: "*" 31 | metrics: 32 | tags: 33 | application: ${spring.application.name} 34 | distribution: 35 | percentiles-histogram: 36 | all: true 37 | http.server.requests: true 38 | opentelemetry: 39 | resource-attributes: 40 | application: ${spring.application.name} 41 | "service.name": ${spring.application.name} 42 | otlp: 43 | tracing: 44 | endpoint: http://localhost:4318/v1/traces 45 | tracing: 46 | sampling: 47 | probability: 1.0 48 | prometheus: 49 | metrics: 50 | export: 51 | step: 5s 52 | endpoint: 53 | health: 54 | probes: 55 | enabled: true 56 | show-details: always 57 | show-components: always 58 | 59 | multitenancy: 60 | tenants: 61 | - identifier: dukes 62 | enabled: true 63 | client-id: edge-service 64 | client-secret: rocking-secret 65 | issuer: http://localhost:8080/realms/dukes 66 | - identifier: beans 67 | enabled: true 68 | client-id: edge-service 69 | client-secret: rocking-secret 70 | issuer: http://localhost:8080/realms/beans 71 | - identifier: trixie 72 | enabled: false 73 | client-id: edge-service 74 | client-secret: rocking-secret 75 | issuer: http://localhost:8080/realms/trixie 76 | -------------------------------------------------------------------------------- /edge-service/src/main/resources/logback-spring.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 2000 9 | 10 | ${lokiUri:-http://localhost:3100/loki/api/v1/push} 11 | 12 | 13 | 16 | 17 | ${FILE_LOG_PATTERN} 18 | 19 | true 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /edge-service/src/test/java/com/thomasvitale/edgeservice/EdgeServiceApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.edgeservice; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | 6 | @SpringBootTest 7 | class EdgeServiceApplicationTests { 8 | 9 | @Test 10 | void contextLoads() { 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /instrument-service/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | insert_final_newline = true 8 | 9 | [*.gradle] 10 | indent_size = 4 11 | ij_continuation_indent_size = 8 12 | 13 | [*.java] 14 | indent_size = 4 15 | max_line_length = 120 16 | trim_trailing_whitespace=true 17 | ij_continuation_indent_size = 8 18 | ij_java_imports_layout = java.**, |, javax.**, |, jakarta.**, |, com.**, |, dev.**, |, io.**, |, org.**, |, *, |, $* 19 | ij_java_class_count_to_use_import_on_demand = 99 20 | ij_java_names_count_to_use_import_on_demand = 99 21 | 22 | [*.{yaml,yml}] 23 | indent_size = 2 24 | -------------------------------------------------------------------------------- /instrument-service/.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | 37 | ### VS Code ### 38 | .vscode/ 39 | 40 | ############# 41 | ### macOS ### 42 | ############# 43 | 44 | # General 45 | .DS_Store 46 | *.DS_Store 47 | **/.DS_Store 48 | .AppleDouble 49 | .LSOverride 50 | 51 | # Icon must end with two \r 52 | Icon 53 | 54 | # Thumbnails 55 | ._* 56 | 57 | # Files that might appear in the root of a volume 58 | .DocumentRevisions-V100 59 | .fseventsd 60 | .Spotlight-V100 61 | .TemporaryItems 62 | .Trashes 63 | .VolumeIcon.icns 64 | .com.apple.timemachine.donotpresent 65 | 66 | # Directories potentially created on remote AFP share 67 | .AppleDB 68 | .AppleDesktop 69 | Network Trash Folder 70 | Temporary Items 71 | .apdisk 72 | -------------------------------------------------------------------------------- /instrument-service/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java' 3 | id 'org.springframework.boot' version '3.2.4' 4 | id 'io.spring.dependency-management' version '1.1.4' 5 | id 'org.cyclonedx.bom' version '1.8.2' 6 | } 7 | 8 | group = 'com.thomasvitale' 9 | version = '0.0.1-SNAPSHOT' 10 | 11 | java { 12 | toolchain { 13 | languageVersion = JavaLanguageVersion.of(21) 14 | } 15 | } 16 | 17 | configurations { 18 | compileOnly { 19 | extendsFrom annotationProcessor 20 | } 21 | } 22 | 23 | repositories { 24 | mavenCentral() 25 | } 26 | 27 | dependencies { 28 | implementation 'org.springframework.boot:spring-boot-starter-actuator' 29 | implementation 'org.springframework.boot:spring-boot-starter-cache' 30 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 31 | implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' 32 | implementation 'org.springframework.boot:spring-boot-starter-validation' 33 | implementation 'org.springframework.boot:spring-boot-starter-web' 34 | implementation 'io.micrometer:micrometer-tracing-bridge-otel' 35 | implementation 'io.opentelemetry:opentelemetry-exporter-otlp' 36 | implementation 'net.ttddyy.observation:datasource-micrometer-spring-boot:1.0.3' 37 | implementation 'org.flywaydb:flyway-core' 38 | 39 | runtimeOnly 'io.micrometer:micrometer-registry-prometheus' 40 | runtimeOnly 'org.postgresql:postgresql' 41 | runtimeOnly 'com.github.loki4j:loki-logback-appender:1.5.1' 42 | 43 | annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' 44 | 45 | testAndDevelopmentOnly 'org.springframework.boot:spring-boot-devtools' 46 | 47 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 48 | testImplementation 'org.springframework.boot:spring-boot-testcontainers' 49 | testImplementation 'org.testcontainers:junit-jupiter' 50 | testImplementation 'org.testcontainers:postgresql' 51 | } 52 | 53 | tasks.named('test') { 54 | useJUnitPlatform() 55 | } 56 | 57 | tasks.named('cyclonedxBom') { 58 | outputFormat = "json" 59 | projectType = "application" 60 | schemaVersion = "1.5" 61 | } 62 | -------------------------------------------------------------------------------- /instrument-service/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasVitale/spring-boot-multitenancy/1f9caec0d6ec655f60f002676a4503bb962743e5/instrument-service/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /instrument-service/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /instrument-service/gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /instrument-service/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /instrument-service/settings.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "org.gradle.toolchains.foojay-resolver-convention" version '0.8.0' 3 | } 4 | 5 | rootProject.name = 'instrument-service' 6 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/InstrumentServiceApplication.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan; 6 | import org.springframework.cache.annotation.EnableCaching; 7 | 8 | @SpringBootApplication 9 | @ConfigurationPropertiesScan 10 | @EnableCaching 11 | public class InstrumentServiceApplication { 12 | 13 | public static void main(String[] args) { 14 | SpringApplication.run(InstrumentServiceApplication.class, args); 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/config/SecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.config; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | 5 | import com.thomasvitale.instrumentservice.multitenancy.web.TenantContextFilter; 6 | 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.context.annotation.Configuration; 9 | import org.springframework.security.authentication.AuthenticationManagerResolver; 10 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 11 | import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; 12 | import org.springframework.security.web.SecurityFilterChain; 13 | 14 | @Configuration(proxyBeanMethods = false) 15 | public class SecurityConfig { 16 | 17 | @Bean 18 | SecurityFilterChain securityFilterChain( 19 | HttpSecurity http, 20 | AuthenticationManagerResolver authenticationManagerResolver, 21 | TenantContextFilter tenantContextFilter 22 | ) throws Exception { 23 | return http 24 | .authorizeHttpRequests(request -> request 25 | .requestMatchers("/actuator/**").permitAll() 26 | .anyRequest().authenticated()) 27 | .oauth2ResourceServer(oauth2 -> oauth2.authenticationManagerResolver(authenticationManagerResolver)) 28 | .addFilterBefore(tenantContextFilter, BearerTokenAuthenticationFilter.class) 29 | .build(); 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/demo/DataConfig.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.demo; 2 | 3 | import java.util.List; 4 | 5 | import com.thomasvitale.instrumentservice.instrument.Instrument; 6 | import com.thomasvitale.instrumentservice.instrument.InstrumentRepository; 7 | import com.thomasvitale.instrumentservice.multitenancy.context.TenantContextHolder; 8 | 9 | import org.springframework.boot.context.event.ApplicationReadyEvent; 10 | import org.springframework.context.annotation.Configuration; 11 | import org.springframework.context.event.EventListener; 12 | 13 | @Configuration(proxyBeanMethods = false) 14 | public class DataConfig { 15 | 16 | private final InstrumentRepository instrumentRepository; 17 | 18 | public DataConfig(InstrumentRepository instrumentRepository) { 19 | this.instrumentRepository = instrumentRepository; 20 | } 21 | 22 | @EventListener(ApplicationReadyEvent.class) 23 | public void loadTestData() { 24 | TenantContextHolder.setTenantIdentifier("dukes"); 25 | if (instrumentRepository.count() == 0) { 26 | var piano = new Instrument("Steinway", "piano"); 27 | var cello = new Instrument("Cello", "string"); 28 | var guitar = new Instrument("Gibson Firebird", "guitar"); 29 | instrumentRepository.saveAll(List.of(piano, cello, guitar)); 30 | } 31 | TenantContextHolder.clear(); 32 | 33 | TenantContextHolder.setTenantIdentifier("beans"); 34 | if (instrumentRepository.count() == 0) { 35 | var organ = new Instrument("Hammond B3", "organ"); 36 | var viola = new Instrument("Viola", "string"); 37 | var guitarFake = new Instrument("Gibson Firebird (Fake)", "guitar"); 38 | instrumentRepository.saveAll(List.of(organ, viola, guitarFake)); 39 | } 40 | TenantContextHolder.clear(); 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/demo/PathConfig.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.demo; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; 5 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 6 | 7 | @Configuration(proxyBeanMethods = false) 8 | public class PathConfig implements WebMvcConfigurer { 9 | 10 | /** 11 | * This is for making the demo work locally when calling the app 12 | * from a browser. Without the trailing slash, browsers would not 13 | * call the local application. Instead, they would try to 14 | * reach it on the internet. 15 | */ 16 | @Override 17 | public void configurePathMatch(PathMatchConfigurer configurer) { 18 | configurer.setUseTrailingSlashMatch(true); 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/instrument/Instrument.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.instrument; 2 | 3 | import java.util.UUID; 4 | 5 | import jakarta.persistence.Entity; 6 | import jakarta.persistence.GeneratedValue; 7 | import jakarta.persistence.GenerationType; 8 | import jakarta.persistence.Id; 9 | import jakarta.validation.constraints.NotEmpty; 10 | 11 | @Entity 12 | public class Instrument { 13 | 14 | @Id 15 | @GeneratedValue(strategy=GenerationType.UUID) 16 | private UUID id; 17 | 18 | @NotEmpty 19 | private String name; 20 | 21 | private String type; 22 | 23 | public Instrument() {} 24 | 25 | public Instrument(String name, String type) { 26 | this.name = name; 27 | this.type = type; 28 | } 29 | 30 | public Instrument(UUID id, String name, String type) { 31 | this.name = name; 32 | this.type = type; 33 | } 34 | 35 | public UUID getId() { 36 | return id; 37 | } 38 | 39 | public void setId(UUID id) { 40 | this.id = id; 41 | } 42 | 43 | public String getName() { 44 | return name; 45 | } 46 | 47 | public void setName(String name) { 48 | this.name = name; 49 | } 50 | 51 | public String getType() { 52 | return type; 53 | } 54 | 55 | public void setType(String type) { 56 | this.type = type; 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/instrument/InstrumentController.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.instrument; 2 | 3 | import java.util.List; 4 | 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | import org.springframework.cache.annotation.Cacheable; 8 | import org.springframework.web.bind.annotation.GetMapping; 9 | import org.springframework.web.bind.annotation.PathVariable; 10 | import org.springframework.web.bind.annotation.PostMapping; 11 | import org.springframework.web.bind.annotation.RequestBody; 12 | import org.springframework.web.bind.annotation.RequestMapping; 13 | import org.springframework.web.bind.annotation.RestController; 14 | 15 | @RestController 16 | @RequestMapping("instruments") 17 | public class InstrumentController { 18 | 19 | private static final Logger log = LoggerFactory.getLogger(InstrumentController.class); 20 | private final InstrumentRepository instrumentRepository; 21 | 22 | InstrumentController(InstrumentRepository instrumentRepository) { 23 | this.instrumentRepository = instrumentRepository; 24 | } 25 | 26 | @GetMapping 27 | List getInstruments() { 28 | log.info("Returning all instruments"); 29 | return instrumentRepository.findAll(); 30 | } 31 | 32 | @GetMapping("{type}") 33 | @Cacheable(cacheNames = "instrumentTypes", keyGenerator = "tenantKeyGenerator") 34 | List getInstrumentByType(@PathVariable String type) { 35 | log.info("Returning instrument of type: {}", type); 36 | return instrumentRepository.findByType(type); 37 | } 38 | 39 | @PostMapping 40 | Instrument addInstrument(@RequestBody Instrument instrument) { 41 | log.info("Adding instrument: {}", instrument.getName()); 42 | return instrumentRepository.save(instrument); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/instrument/InstrumentRepository.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.instrument; 2 | 3 | import java.util.List; 4 | import java.util.UUID; 5 | 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | 8 | public interface InstrumentRepository extends JpaRepository { 9 | List findByType(String type); 10 | } 11 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/multitenancy/context/TenantContextHolder.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.multitenancy.context; 2 | 3 | import com.thomasvitale.instrumentservice.multitenancy.exceptions.TenantNotFoundException; 4 | 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | import org.springframework.lang.Nullable; 8 | import org.springframework.util.Assert; 9 | import org.springframework.util.StringUtils; 10 | 11 | /** 12 | * A shared, thread-local store for the current tenant. 13 | */ 14 | public final class TenantContextHolder { 15 | 16 | private static final Logger log = LoggerFactory.getLogger(TenantContextHolder.class); 17 | 18 | private static final ThreadLocal tenantIdentifier = new ThreadLocal<>(); 19 | 20 | private TenantContextHolder() { 21 | } 22 | 23 | public static void setTenantIdentifier(String tenant) { 24 | Assert.hasText(tenant, "tenant cannot be empty"); 25 | log.trace("Setting current tenant to: {}", tenant); 26 | tenantIdentifier.set(tenant); 27 | } 28 | 29 | @Nullable 30 | public static String getTenantIdentifier() { 31 | return tenantIdentifier.get(); 32 | } 33 | 34 | public static String getRequiredTenantIdentifier() { 35 | var tenant = getTenantIdentifier(); 36 | if (!StringUtils.hasText(tenant)) { 37 | throw new TenantNotFoundException("No tenant found in the current context"); 38 | } 39 | return tenant; 40 | } 41 | 42 | public static void clear() { 43 | log.trace("Clearing current tenant"); 44 | tenantIdentifier.remove(); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/multitenancy/context/resolvers/HttpHeaderTenantResolver.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.multitenancy.context.resolvers; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | 5 | import org.springframework.lang.Nullable; 6 | import org.springframework.stereotype.Component; 7 | 8 | /** 9 | * Strategy used to resolve the current tenant from a header in an HTTP request. 10 | */ 11 | @Component 12 | public class HttpHeaderTenantResolver implements TenantResolver { 13 | 14 | private static final String TENANT_HEADER = "X-TenantId"; 15 | 16 | @Override 17 | @Nullable 18 | public String resolveTenantIdentifier(HttpServletRequest request) { 19 | return request.getHeader(TENANT_HEADER); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/multitenancy/context/resolvers/TenantResolver.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.multitenancy.context.resolvers; 2 | 3 | import org.springframework.lang.Nullable; 4 | 5 | /** 6 | * Strategy used to resolve the current tenant from a given source context. 7 | */ 8 | @FunctionalInterface 9 | public interface TenantResolver { 10 | 11 | /** 12 | * Resolves a tenant identifier from the given source. 13 | */ 14 | @Nullable 15 | String resolveTenantIdentifier(T source); 16 | 17 | } 18 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/multitenancy/data/cache/TenantKeyGenerator.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.multitenancy.data.cache; 2 | 3 | import java.lang.reflect.Method; 4 | 5 | import com.thomasvitale.instrumentservice.multitenancy.context.TenantContextHolder; 6 | 7 | import org.springframework.cache.interceptor.KeyGenerator; 8 | import org.springframework.cache.interceptor.SimpleKeyGenerator; 9 | import org.springframework.stereotype.Component; 10 | 11 | /** 12 | * An implementation of {@link KeyGenerator} that generates cache keys combining the 13 | * current tenant identifier with the given method and parameters. 14 | */ 15 | @Component 16 | public final class TenantKeyGenerator implements KeyGenerator { 17 | 18 | @Override 19 | public Object generate(Object target, Method method, Object... params) { 20 | return SimpleKeyGenerator.generateKey(TenantContextHolder.getRequiredTenantIdentifier(), params); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/multitenancy/data/flyway/TenantFlywayMigrationInitializer.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.multitenancy.data.flyway; 2 | 3 | import javax.sql.DataSource; 4 | 5 | import com.thomasvitale.instrumentservice.multitenancy.tenantdetails.TenantDetailsService; 6 | 7 | import org.flywaydb.core.Flyway; 8 | import org.springframework.beans.factory.InitializingBean; 9 | import org.springframework.core.Ordered; 10 | import org.springframework.stereotype.Component; 11 | 12 | @Component 13 | public class TenantFlywayMigrationInitializer implements InitializingBean, Ordered { 14 | 15 | private static final String TENANT_MIGRATION_LOCATION = "db/migration/tenant"; 16 | 17 | private final DataSource dataSource; 18 | private final Flyway defaultFlyway; 19 | private final TenantDetailsService tenantDetailsService; 20 | 21 | public TenantFlywayMigrationInitializer(DataSource dataSource, Flyway defaultFlyway, TenantDetailsService tenantDetailsService) { 22 | this.dataSource = dataSource; 23 | this.defaultFlyway = defaultFlyway; 24 | this.tenantDetailsService = tenantDetailsService; 25 | } 26 | 27 | @Override 28 | public void afterPropertiesSet() { 29 | tenantDetailsService.loadAllTenants().forEach(tenant -> { 30 | Flyway flyway = tenantFlyway(tenant.schema()); 31 | flyway.migrate(); 32 | }); 33 | } 34 | 35 | private Flyway tenantFlyway(String schema) { 36 | return Flyway.configure() 37 | .configuration(defaultFlyway.getConfiguration()) 38 | .locations(TENANT_MIGRATION_LOCATION) 39 | .dataSource(dataSource) 40 | .schemas(schema) 41 | .load(); 42 | } 43 | 44 | @Override 45 | public int getOrder() { 46 | // Executed after the default schema initialization in FlywayMigrationInitializer. 47 | return 1; 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/multitenancy/data/hibernate/ConnectionProvider.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.multitenancy.data.hibernate; 2 | 3 | import java.sql.Connection; 4 | import java.sql.SQLException; 5 | import java.util.Map; 6 | 7 | import javax.sql.DataSource; 8 | 9 | import com.thomasvitale.instrumentservice.multitenancy.tenantdetails.TenantDetailsService; 10 | 11 | import org.hibernate.cfg.AvailableSettings; 12 | import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider; 13 | import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer; 14 | import org.springframework.stereotype.Component; 15 | 16 | @Component 17 | public class ConnectionProvider implements MultiTenantConnectionProvider, HibernatePropertiesCustomizer { 18 | 19 | private final DataSource dataSource; 20 | private final TenantDetailsService tenantDetailsService; 21 | 22 | ConnectionProvider(DataSource dataSource, TenantDetailsService tenantDetailsService) { 23 | this.dataSource = dataSource; 24 | this.tenantDetailsService = tenantDetailsService; 25 | } 26 | 27 | @Override 28 | public Connection getAnyConnection() throws SQLException { 29 | return getConnection("DEFAULT"); 30 | } 31 | 32 | @Override 33 | public void releaseAnyConnection(Connection connection) throws SQLException { 34 | connection.close(); 35 | } 36 | 37 | @Override 38 | public Connection getConnection(String tenantIdentifier) throws SQLException { 39 | var tenantDetails = tenantDetailsService.loadTenantByIdentifier(tenantIdentifier); 40 | var schema = tenantDetails != null ? tenantDetails.schema() : tenantIdentifier; 41 | 42 | Connection connection = dataSource.getConnection(); 43 | connection.setSchema(schema); 44 | return connection; 45 | } 46 | 47 | @Override 48 | public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException { 49 | connection.setSchema("DEFAULT"); 50 | connection.close(); 51 | } 52 | 53 | @Override 54 | public boolean supportsAggressiveRelease() { 55 | return true; 56 | } 57 | 58 | @Override 59 | public boolean isUnwrappableAs(Class unwrapType) { 60 | return false; 61 | } 62 | 63 | @Override 64 | public T unwrap(Class unwrapType) { 65 | throw new UnsupportedOperationException("Unimplemented method 'unwrap'."); 66 | } 67 | 68 | @Override 69 | public void customize(Map hibernateProperties) { 70 | hibernateProperties.put(AvailableSettings.MULTI_TENANT_CONNECTION_PROVIDER, this); 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/multitenancy/data/hibernate/TenantIdentifierResolver.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.multitenancy.data.hibernate; 2 | 3 | import java.util.Map; 4 | import java.util.Objects; 5 | 6 | import com.thomasvitale.instrumentservice.multitenancy.context.TenantContextHolder; 7 | 8 | import org.hibernate.cfg.AvailableSettings; 9 | import org.hibernate.context.spi.CurrentTenantIdentifierResolver; 10 | import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer; 11 | import org.springframework.stereotype.Component; 12 | 13 | @Component 14 | public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver, HibernatePropertiesCustomizer { 15 | 16 | public static final String DEFAULT_TENANT = "DEFAULT"; 17 | 18 | @Override 19 | public String resolveCurrentTenantIdentifier() { 20 | return Objects.requireNonNullElse(TenantContextHolder.getTenantIdentifier(), DEFAULT_TENANT); 21 | } 22 | 23 | @Override 24 | public boolean validateExistingCurrentSessions() { 25 | return true; 26 | } 27 | 28 | @Override 29 | public void customize(Map hibernateProperties) { 30 | hibernateProperties.put(AvailableSettings.MULTI_TENANT_IDENTIFIER_RESOLVER, this); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/multitenancy/exceptions/TenantNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.multitenancy.exceptions; 2 | 3 | /** 4 | * Thrown when no tenant information is found in a given context. 5 | */ 6 | public class TenantNotFoundException extends IllegalStateException { 7 | 8 | public TenantNotFoundException() { 9 | super("No tenant found in the current context"); 10 | } 11 | 12 | public TenantNotFoundException(String message) { 13 | super(message); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/multitenancy/exceptions/TenantResolutionException.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.multitenancy.exceptions; 2 | 3 | /** 4 | * Thrown when an error occurred during the tenant resolution process. 5 | */ 6 | public class TenantResolutionException extends IllegalStateException { 7 | 8 | public TenantResolutionException() { 9 | super("Error when trying to resolve the current tenant"); 10 | } 11 | 12 | public TenantResolutionException(String message) { 13 | super(message); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/multitenancy/security/TenantAuthenticationManagerResolver.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.multitenancy.security; 2 | 3 | import java.util.Map; 4 | import java.util.concurrent.ConcurrentHashMap; 5 | 6 | import jakarta.servlet.http.HttpServletRequest; 7 | 8 | import com.thomasvitale.instrumentservice.multitenancy.context.TenantContextHolder; 9 | import com.thomasvitale.instrumentservice.multitenancy.tenantdetails.TenantDetailsService; 10 | 11 | import org.springframework.security.authentication.AuthenticationManager; 12 | import org.springframework.security.authentication.AuthenticationManagerResolver; 13 | import org.springframework.security.oauth2.jwt.JwtDecoders; 14 | import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; 15 | import org.springframework.stereotype.Component; 16 | 17 | @Component 18 | public class TenantAuthenticationManagerResolver implements AuthenticationManagerResolver { 19 | 20 | private static final Map authenticationManagers = new ConcurrentHashMap<>(); 21 | private final TenantDetailsService tenantDetailsService; 22 | 23 | public TenantAuthenticationManagerResolver(TenantDetailsService tenantDetailsService) { 24 | this.tenantDetailsService = tenantDetailsService; 25 | } 26 | 27 | @Override 28 | public AuthenticationManager resolve(HttpServletRequest request) { 29 | var tenantId = TenantContextHolder.getRequiredTenantIdentifier(); 30 | return authenticationManagers.computeIfAbsent(tenantId, this::buildAuthenticationManager); 31 | } 32 | 33 | private AuthenticationManager buildAuthenticationManager(String tenantId) { 34 | var issuerUri = tenantDetailsService.loadTenantByIdentifier(tenantId).issuer(); 35 | var jwtAuthenticationprovider = new JwtAuthenticationProvider(JwtDecoders.fromIssuerLocation(issuerUri)); 36 | return jwtAuthenticationprovider::authenticate; 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/multitenancy/tenantdetails/PropertiesTenantDetailsService.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.multitenancy.tenantdetails; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.stereotype.Service; 6 | 7 | @Service 8 | public class PropertiesTenantDetailsService implements TenantDetailsService { 9 | 10 | private final TenantDetailsProperties tenantDetailsProperties; 11 | 12 | public PropertiesTenantDetailsService(TenantDetailsProperties tenantDetailsProperties) { 13 | this.tenantDetailsProperties = tenantDetailsProperties; 14 | } 15 | 16 | @Override 17 | public List loadAllTenants() { 18 | return tenantDetailsProperties.tenants(); 19 | } 20 | 21 | @Override 22 | public TenantDetails loadTenantByIdentifier(String identifier) { 23 | return tenantDetailsProperties.tenants().stream() 24 | .filter(TenantDetails::enabled) 25 | .filter(tenantDetails -> identifier.equals(tenantDetails.identifier())) 26 | .findFirst().orElse(null); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/multitenancy/tenantdetails/TenantDetails.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.multitenancy.tenantdetails; 2 | 3 | /** 4 | * Provides core tenant information. 5 | */ 6 | public record TenantDetails( 7 | String identifier, 8 | boolean enabled, 9 | String schema, 10 | String issuer 11 | ) {} 12 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/multitenancy/tenantdetails/TenantDetailsProperties.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.multitenancy.tenantdetails; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.boot.context.properties.ConfigurationProperties; 6 | 7 | @ConfigurationProperties(prefix = "multitenancy") 8 | public record TenantDetailsProperties(List tenants) { } 9 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/multitenancy/tenantdetails/TenantDetailsService.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.multitenancy.tenantdetails; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.lang.Nullable; 6 | 7 | /** 8 | * Core interface which loads tenant-specific data. 9 | * It is used throughout the framework as a tenant DAO. 10 | */ 11 | public interface TenantDetailsService { 12 | 13 | List loadAllTenants(); 14 | 15 | @Nullable 16 | TenantDetails loadTenantByIdentifier(String identifier); 17 | 18 | } 19 | -------------------------------------------------------------------------------- /instrument-service/src/main/java/com/thomasvitale/instrumentservice/multitenancy/web/TenantContextFilter.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice.multitenancy.web; 2 | 3 | import java.io.IOException; 4 | 5 | import jakarta.servlet.FilterChain; 6 | import jakarta.servlet.ServletException; 7 | import jakarta.servlet.http.HttpServletRequest; 8 | import jakarta.servlet.http.HttpServletResponse; 9 | 10 | import com.thomasvitale.instrumentservice.multitenancy.context.TenantContextHolder; 11 | import com.thomasvitale.instrumentservice.multitenancy.context.resolvers.HttpHeaderTenantResolver; 12 | import com.thomasvitale.instrumentservice.multitenancy.exceptions.TenantResolutionException; 13 | import com.thomasvitale.instrumentservice.multitenancy.tenantdetails.TenantDetailsService; 14 | 15 | import io.micrometer.common.KeyValue; 16 | 17 | import org.slf4j.MDC; 18 | import org.springframework.stereotype.Component; 19 | import org.springframework.util.StringUtils; 20 | import org.springframework.web.filter.OncePerRequestFilter; 21 | import org.springframework.web.filter.ServerHttpObservationFilter; 22 | 23 | /** 24 | * Establish a tenant context from an HTTP request, if tenant information is available. 25 | */ 26 | @Component 27 | public class TenantContextFilter extends OncePerRequestFilter { 28 | 29 | private final HttpHeaderTenantResolver httpRequestTenantResolver; 30 | private final TenantDetailsService tenantDetailsService; 31 | 32 | public TenantContextFilter(HttpHeaderTenantResolver httpHeaderTenantResolver, TenantDetailsService tenantDetailsService) { 33 | this.httpRequestTenantResolver = httpHeaderTenantResolver; 34 | this.tenantDetailsService = tenantDetailsService; 35 | } 36 | 37 | @Override 38 | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { 39 | var tenantIdentifier = httpRequestTenantResolver.resolveTenantIdentifier(request); 40 | 41 | if (StringUtils.hasText(tenantIdentifier) && isTenantValid(tenantIdentifier)) { 42 | TenantContextHolder.setTenantIdentifier(tenantIdentifier); 43 | configureLogs(tenantIdentifier); 44 | configureTraces(tenantIdentifier, request); 45 | } else { 46 | throw new TenantResolutionException("A valid tenant must be specified for requests to %s".formatted(request.getRequestURI())); 47 | } 48 | 49 | try { 50 | filterChain.doFilter(request, response); 51 | } finally { 52 | clear(); 53 | } 54 | } 55 | 56 | @Override 57 | protected boolean shouldNotFilter(HttpServletRequest request) { 58 | return request.getRequestURI().startsWith("/actuator"); 59 | } 60 | 61 | private boolean isTenantValid(String tenantIdentifier) { 62 | var tenantDetails = tenantDetailsService.loadTenantByIdentifier(tenantIdentifier); 63 | return tenantDetails != null && tenantDetails.enabled(); 64 | } 65 | 66 | private void configureLogs(String tenantId) { 67 | MDC.put("tenantId", tenantId); 68 | } 69 | 70 | private void configureTraces(String tenantId, HttpServletRequest request) { 71 | ServerHttpObservationFilter.findObservationContext(request).ifPresent(context -> 72 | context.addHighCardinalityKeyValue(KeyValue.of("tenant.id", tenantId))); 73 | } 74 | 75 | private void clear() { 76 | MDC.remove("tenantId"); 77 | TenantContextHolder.clear(); 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /instrument-service/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8181 3 | 4 | spring: 5 | application: 6 | name: instrument-service 7 | flyway: 8 | locations: classpath:db/migration/default 9 | schemas: default 10 | 11 | logging: 12 | pattern: 13 | correlation: '[%X{traceId:-}-%X{spanId:-}] [%X{tenantId:-}] ' 14 | 15 | management: 16 | endpoints: 17 | web: 18 | exposure: 19 | include: "*" 20 | metrics: 21 | tags: 22 | application: ${spring.application.name} 23 | distribution: 24 | percentiles-histogram: 25 | all: true 26 | http.server.requests: true 27 | opentelemetry: 28 | resource-attributes: 29 | application: ${spring.application.name} 30 | "service.name": ${spring.application.name} 31 | otlp: 32 | tracing: 33 | endpoint: http://localhost:4318/v1/traces 34 | tracing: 35 | sampling: 36 | probability: 1.0 37 | prometheus: 38 | metrics: 39 | export: 40 | step: 5s 41 | endpoint: 42 | health: 43 | probes: 44 | enabled: true 45 | show-details: always 46 | show-components: always 47 | 48 | multitenancy: 49 | tenants: 50 | - identifier: dukes 51 | enabled: true 52 | schema: DUKES 53 | issuer: http://localhost:8080/realms/dukes 54 | - identifier: beans 55 | enabled: true 56 | schema: BEANS 57 | issuer: http://localhost:8080/realms/beans 58 | - identifier: trixie 59 | enabled: false 60 | schema: TRIXIE 61 | issuer: http://localhost:8080/realms/trixie 62 | -------------------------------------------------------------------------------- /instrument-service/src/main/resources/db/migration/default/V1__Creative_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE creative( 2 | notreally UUID PRIMARY KEY 3 | ); 4 | -------------------------------------------------------------------------------- /instrument-service/src/main/resources/db/migration/tenant/V1__Instrument_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE instrument( 2 | id UUID PRIMARY KEY, 3 | name VARCHAR(255) NOT NULL, 4 | type VARCHAR(255) 5 | ); 6 | -------------------------------------------------------------------------------- /instrument-service/src/main/resources/logback-spring.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 2000 9 | 10 | ${lokiUri:-http://localhost:3100/loki/api/v1/push} 11 | 12 | 13 | 16 | 17 | ${FILE_LOG_PATTERN} 18 | 19 | true 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /instrument-service/src/test/java/com/thomasvitale/instrumentservice/InstrumentServiceApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; 6 | import org.springframework.context.annotation.Import; 7 | 8 | @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) 9 | @Import(TestInstrumentServiceApplication.class) 10 | class InstrumentServiceApplicationTests { 11 | 12 | @Test 13 | void contextLoads() { 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /instrument-service/src/test/java/com/thomasvitale/instrumentservice/TestInstrumentServiceApplication.java: -------------------------------------------------------------------------------- 1 | package com.thomasvitale.instrumentservice; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.devtools.restart.RestartScope; 5 | import org.springframework.boot.test.context.TestConfiguration; 6 | import org.springframework.boot.testcontainers.service.connection.ServiceConnection; 7 | import org.springframework.context.annotation.Bean; 8 | import org.testcontainers.containers.PostgreSQLContainer; 9 | 10 | @TestConfiguration(proxyBeanMethods = false) 11 | public class TestInstrumentServiceApplication { 12 | 13 | @Bean 14 | @RestartScope 15 | @ServiceConnection 16 | PostgreSQLContainer postgreSQLContainer() { 17 | return new PostgreSQLContainer<>("postgres:15.6"); 18 | } 19 | 20 | public static void main(String[] args) { 21 | SpringApplication.from(InstrumentServiceApplication::main) 22 | .with(TestInstrumentServiceApplication.class) 23 | .run(args); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /platform/alloy/config.alloy: -------------------------------------------------------------------------------- 1 | /******************************************** 2 | * Alloy 3 | ********************************************/ 4 | 5 | logging { 6 | level = "info" 7 | format = "logfmt" 8 | } 9 | 10 | tracing { 11 | sampling_fraction = 0.1 12 | write_to = [otelcol.exporter.otlp.default.input] 13 | } 14 | 15 | /******************************************** 16 | * Metrics 17 | ********************************************/ 18 | 19 | prometheus.exporter.self "default" {} 20 | 21 | prometheus.exporter.postgres "default" { 22 | data_source_names = [ 23 | "postgresql://" + env("POSTGRES_USER") + ":" + env("POSTGRES_PASSWORD") + "@postgres:5432/grafana?sslmode=disable", 24 | ] 25 | } 26 | 27 | prometheus.scrape "exporters" { 28 | targets = concat( 29 | prometheus.exporter.self.default.targets, 30 | prometheus.exporter.postgres.default.targets, 31 | ) 32 | 33 | job_name = "integrations" 34 | scrape_interval = "10s" 35 | scrape_timeout = "10s" 36 | 37 | forward_to = [prometheus.remote_write.default.receiver] 38 | } 39 | 40 | prometheus.scrape "services" { 41 | targets = [ 42 | { 43 | __address__ = "host.docker.internal:80", 44 | __metrics_path__ = "/actuator/prometheus", 45 | job = "services/spring-boot", 46 | }, 47 | { 48 | __address__ = "host.docker.internal:8181", 49 | __metrics_path__ = "/actuator/prometheus", 50 | job = "services/spring-boot", 51 | }, 52 | { 53 | __address__ = "host.docker.internal:8282", 54 | __metrics_path__ = "/actuator/prometheus", 55 | job = "services/spring-boot", 56 | }, 57 | { 58 | __address__ = "host.docker.internal:8080", 59 | __metrics_path__ = "/metrics", 60 | job = "services/quarkus", 61 | application = "keycloak", 62 | }, 63 | ] 64 | 65 | job_name = "services" 66 | scrape_interval = "5s" 67 | scrape_timeout = "5s" 68 | 69 | forward_to = [prometheus.remote_write.default.receiver] 70 | } 71 | 72 | prometheus.remote_write "default" { 73 | endpoint { 74 | url = env("PROMETHEUS_URL") 75 | send_native_histograms = true 76 | } 77 | external_labels = { 78 | environment = env("ENVIRONMENT"), 79 | hostname = env("HOSTNAME"), 80 | } 81 | wal { 82 | truncate_frequency = "15m" 83 | } 84 | } 85 | 86 | /******************************************** 87 | * Logs 88 | ********************************************/ 89 | 90 | discovery.docker "containers" { 91 | host = "unix:///var/run/docker.sock" 92 | } 93 | 94 | discovery.relabel "containers" { 95 | targets = discovery.docker.containers.targets 96 | 97 | rule { 98 | action = "replace" 99 | source_labels = ["__meta_docker_container_name"] 100 | regex = "/(.*)" 101 | target_label = "application" 102 | } 103 | 104 | rule { 105 | action = "replace" 106 | source_labels = ["__meta_docker_container_label_org_opencontainers_image_version"] 107 | regex = "/(.*)" 108 | target_label = "version" 109 | } 110 | } 111 | 112 | loki.source.docker "services" { 113 | host = "unix:///var/run/docker.sock" 114 | targets = discovery.relabel.containers.output 115 | forward_to = [loki.write.default.receiver] 116 | } 117 | 118 | loki.write "default" { 119 | endpoint { 120 | url = env("LOKI_URL") 121 | } 122 | external_labels = { 123 | environment = env("ENVIRONMENT"), 124 | hostname = env("HOSTNAME"), 125 | } 126 | wal { 127 | enabled = true 128 | } 129 | } 130 | 131 | /******************************************** 132 | * Traces 133 | ********************************************/ 134 | 135 | otelcol.receiver.otlp "default" { 136 | grpc { 137 | endpoint = "localhost:4317" 138 | } 139 | 140 | http { 141 | endpoint = "localhost:4318" 142 | } 143 | 144 | output { 145 | logs = [otelcol.processor.memory_limiter.default.input] 146 | metrics = [otelcol.processor.memory_limiter.default.input] 147 | traces = [otelcol.processor.memory_limiter.default.input] 148 | } 149 | } 150 | 151 | otelcol.processor.memory_limiter "default" { 152 | check_interval = "1s" 153 | 154 | limit_percentage = 85 155 | spike_limit_percentage = 20 156 | 157 | output { 158 | logs = [otelcol.processor.batch.default.input] 159 | metrics = [otelcol.processor.batch.default.input] 160 | traces = [otelcol.processor.batch.default.input] 161 | } 162 | } 163 | 164 | otelcol.processor.batch "default" { 165 | output { 166 | metrics = [otelcol.exporter.otlp.default.input] 167 | logs = [otelcol.exporter.otlp.default.input] 168 | traces = [otelcol.exporter.otlp.default.input] 169 | } 170 | } 171 | 172 | otelcol.exporter.otlp "default" { 173 | client { 174 | endpoint = env("TEMPO_URL") 175 | tls { 176 | insecure = true 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /platform/grafana/README.md: -------------------------------------------------------------------------------- 1 | The Grafana dashboards included in this package are modified versions of the following: 2 | 3 | * JVM Dashboard -> https://grafana.com/grafana/dashboards/4701 4 | * Spring Boot -> https://grafana.com/grafana/dashboards/10280-microservices-spring-boot-2-1 5 | * Spring Boot HikariCP / JDBC -> https://grafana.com/grafana/dashboards/6083-spring-boot-hikaricp-jdbc/ 6 | * Prometheus Stats -> https://grafana.com/grafana/dashboards/2-prometheus-stats/ 7 | * Polar -> https://github.com/jonatan-ivanov/teahouse/blob/main/docker/grafana/provisioning/dashboards/tea-api.json 8 | -------------------------------------------------------------------------------- /platform/grafana/dashboards/applications/polar-http.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": { 7 | "type": "grafana", 8 | "uid": "-- Grafana --" 9 | }, 10 | "enable": true, 11 | "hide": true, 12 | "iconColor": "rgba(0, 211, 255, 1)", 13 | "name": "Annotations & Alerts", 14 | "target": { 15 | "limit": 100, 16 | "matchAny": false, 17 | "tags": [], 18 | "type": "dashboard" 19 | }, 20 | "type": "dashboard" 21 | } 22 | ] 23 | }, 24 | "editable": true, 25 | "fiscalYearStartMonth": 0, 26 | "graphTooltip": 0, 27 | "id": 6, 28 | "links": [], 29 | "liveNow": false, 30 | "panels": [ 31 | { 32 | "datasource": "Prometheus", 33 | "fieldConfig": { 34 | "defaults": { 35 | "color": { 36 | "mode": "palette-classic" 37 | }, 38 | "custom": { 39 | "axisCenteredZero": false, 40 | "axisColorMode": "text", 41 | "axisLabel": "", 42 | "axisPlacement": "auto", 43 | "barAlignment": 0, 44 | "drawStyle": "line", 45 | "fillOpacity": 0, 46 | "gradientMode": "none", 47 | "hideFrom": { 48 | "legend": false, 49 | "tooltip": false, 50 | "viz": false 51 | }, 52 | "lineInterpolation": "linear", 53 | "lineWidth": 1, 54 | "pointSize": 5, 55 | "scaleDistribution": { 56 | "type": "linear" 57 | }, 58 | "showPoints": "auto", 59 | "spanNulls": false, 60 | "stacking": { 61 | "group": "A", 62 | "mode": "none" 63 | }, 64 | "thresholdsStyle": { 65 | "mode": "off" 66 | } 67 | }, 68 | "mappings": [], 69 | "thresholds": { 70 | "mode": "absolute", 71 | "steps": [ 72 | { 73 | "color": "green", 74 | "value": null 75 | }, 76 | { 77 | "color": "red", 78 | "value": 80 79 | } 80 | ] 81 | }, 82 | "unit": "s" 83 | }, 84 | "overrides": [] 85 | }, 86 | "gridPos": { 87 | "h": 9, 88 | "w": 12, 89 | "x": 0, 90 | "y": 0 91 | }, 92 | "id": 6, 93 | "options": { 94 | "legend": { 95 | "calcs": [], 96 | "displayMode": "list", 97 | "placement": "bottom", 98 | "showLegend": true 99 | }, 100 | "tooltip": { 101 | "mode": "single", 102 | "sort": "none" 103 | } 104 | }, 105 | "targets": [ 106 | { 107 | "datasource": "Prometheus", 108 | "editorMode": "code", 109 | "exemplar": true, 110 | "expr": "histogram_quantile(1.00, sum(rate(http_server_requests_seconds_bucket{application=~\"$application\", uri=~\"$uri\"}[$__rate_interval])) by (le))", 111 | "legendFormat": "max", 112 | "range": true, 113 | "refId": "A" 114 | }, 115 | { 116 | "datasource": "Prometheus", 117 | "editorMode": "code", 118 | "exemplar": true, 119 | "expr": "histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{application=~\"$application\", uri=~\"$uri\"}[$__rate_interval])) by (le))", 120 | "hide": false, 121 | "legendFormat": "tp99", 122 | "range": true, 123 | "refId": "B" 124 | }, 125 | { 126 | "datasource": "Prometheus", 127 | "editorMode": "code", 128 | "exemplar": true, 129 | "expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{application=~\"$application\", uri=~\"$uri\"}[$__rate_interval])) by (le))", 130 | "hide": false, 131 | "legendFormat": "tp95", 132 | "range": true, 133 | "refId": "C" 134 | } 135 | ], 136 | "title": "$application latency for $uri", 137 | "type": "timeseries" 138 | }, 139 | { 140 | "datasource": "Prometheus", 141 | "fieldConfig": { 142 | "defaults": { 143 | "color": { 144 | "mode": "palette-classic" 145 | }, 146 | "custom": { 147 | "axisCenteredZero": false, 148 | "axisColorMode": "text", 149 | "axisLabel": "", 150 | "axisPlacement": "auto", 151 | "barAlignment": 0, 152 | "drawStyle": "line", 153 | "fillOpacity": 0, 154 | "gradientMode": "none", 155 | "hideFrom": { 156 | "legend": false, 157 | "tooltip": false, 158 | "viz": false 159 | }, 160 | "lineInterpolation": "linear", 161 | "lineWidth": 1, 162 | "pointSize": 5, 163 | "scaleDistribution": { 164 | "type": "linear" 165 | }, 166 | "showPoints": "auto", 167 | "spanNulls": false, 168 | "stacking": { 169 | "group": "A", 170 | "mode": "none" 171 | }, 172 | "thresholdsStyle": { 173 | "mode": "off" 174 | } 175 | }, 176 | "mappings": [], 177 | "thresholds": { 178 | "mode": "absolute", 179 | "steps": [ 180 | { 181 | "color": "green", 182 | "value": null 183 | }, 184 | { 185 | "color": "red", 186 | "value": 80 187 | } 188 | ] 189 | } 190 | }, 191 | "overrides": [] 192 | }, 193 | "gridPos": { 194 | "h": 9, 195 | "w": 12, 196 | "x": 12, 197 | "y": 0 198 | }, 199 | "id": 8, 200 | "options": { 201 | "legend": { 202 | "calcs": [], 203 | "displayMode": "list", 204 | "placement": "bottom", 205 | "showLegend": true 206 | }, 207 | "tooltip": { 208 | "mode": "single", 209 | "sort": "none" 210 | } 211 | }, 212 | "targets": [ 213 | { 214 | "datasource": "Prometheus", 215 | "editorMode": "code", 216 | "exemplar": true, 217 | "expr": "sum(rate(http_server_requests_seconds_count{application=~\"$application\", uri=~\"$uri\"}[$__rate_interval])) by (outcome)", 218 | "legendFormat": "{{outcome}}", 219 | "range": true, 220 | "refId": "A" 221 | } 222 | ], 223 | "title": "$application throughput for $uri", 224 | "type": "timeseries" 225 | }, 226 | { 227 | "cards": {}, 228 | "color": { 229 | "cardColor": "#b4ff00", 230 | "colorScale": "sqrt", 231 | "colorScheme": "interpolateSpectral", 232 | "exponent": 0.5, 233 | "mode": "spectrum" 234 | }, 235 | "dataFormat": "tsbuckets", 236 | "datasource": "Prometheus", 237 | "fieldConfig": { 238 | "defaults": { 239 | "custom": { 240 | "hideFrom": { 241 | "legend": false, 242 | "tooltip": false, 243 | "viz": false 244 | }, 245 | "scaleDistribution": { 246 | "type": "linear" 247 | } 248 | } 249 | }, 250 | "overrides": [] 251 | }, 252 | "gridPos": { 253 | "h": 18, 254 | "w": 24, 255 | "x": 0, 256 | "y": 9 257 | }, 258 | "heatmap": {}, 259 | "hideZeroBuckets": true, 260 | "highlightCards": true, 261 | "id": 2, 262 | "legend": { 263 | "show": true 264 | }, 265 | "maxDataPoints": 25, 266 | "options": { 267 | "calculate": false, 268 | "calculation": {}, 269 | "cellGap": 2, 270 | "cellValues": {}, 271 | "color": { 272 | "exponent": 0.5, 273 | "fill": "#b4ff00", 274 | "mode": "scheme", 275 | "reverse": false, 276 | "scale": "exponential", 277 | "scheme": "Spectral", 278 | "steps": 128 279 | }, 280 | "exemplars": { 281 | "color": "rgba(255,0,255,0.7)" 282 | }, 283 | "filterValues": { 284 | "le": 1e-9 285 | }, 286 | "legend": { 287 | "show": true 288 | }, 289 | "rowsFrame": { 290 | "layout": "auto" 291 | }, 292 | "showValue": "never", 293 | "tooltip": { 294 | "show": true, 295 | "yHistogram": false 296 | }, 297 | "yAxis": { 298 | "axisPlacement": "left", 299 | "reverse": false, 300 | "unit": "s" 301 | } 302 | }, 303 | "pluginVersion": "9.3.6", 304 | "reverseYBuckets": true, 305 | "targets": [ 306 | { 307 | "datasource": "Prometheus", 308 | "editorMode": "code", 309 | "exemplar": true, 310 | "expr": "sum(increase(http_server_requests_seconds_bucket{application=~\"$application\", uri=~\"$uri\"}[$__interval])) by (le)", 311 | "format": "heatmap", 312 | "instant": false, 313 | "legendFormat": "{{le}}", 314 | "range": true, 315 | "refId": "A" 316 | } 317 | ], 318 | "title": "$application latency heatmap for $uri", 319 | "tooltip": { 320 | "show": true, 321 | "showHistogram": false 322 | }, 323 | "type": "heatmap", 324 | "xAxis": { 325 | "show": true 326 | }, 327 | "yAxis": { 328 | "format": "s", 329 | "logBase": 1, 330 | "show": true 331 | }, 332 | "yBucketBound": "auto" 333 | } 334 | ], 335 | "refresh": "5s", 336 | "schemaVersion": 37, 337 | "style": "dark", 338 | "tags": [], 339 | "templating": { 340 | "list": [ 341 | { 342 | "allValue": ".*", 343 | "current": { 344 | "selected": true, 345 | "text": "book-service", 346 | "value": "book-service" 347 | }, 348 | "datasource": { 349 | "type": "prometheus", 350 | "uid": "prometheus" 351 | }, 352 | "definition": "label_values(application)", 353 | "hide": 0, 354 | "includeAll": true, 355 | "label": "Application", 356 | "multi": false, 357 | "name": "application", 358 | "options": [], 359 | "query": { 360 | "query": "label_values(application)", 361 | "refId": "StandardVariableQuery" 362 | }, 363 | "refresh": 1, 364 | "regex": "", 365 | "skipUrlSync": false, 366 | "sort": 1, 367 | "type": "query" 368 | }, 369 | { 370 | "allValue": ".*", 371 | "current": { 372 | "selected": true, 373 | "text": "All", 374 | "value": "$__all" 375 | }, 376 | "datasource": { 377 | "type": "prometheus", 378 | "uid": "prometheus" 379 | }, 380 | "definition": "label_values(http_server_requests_seconds_count{application=\"$application\"}, uri)", 381 | "hide": 0, 382 | "includeAll": true, 383 | "label": "URI", 384 | "multi": false, 385 | "name": "uri", 386 | "options": [], 387 | "query": { 388 | "query": "label_values(http_server_requests_seconds_count{application=\"$application\"}, uri)", 389 | "refId": "StandardVariableQuery" 390 | }, 391 | "refresh": 1, 392 | "regex": "", 393 | "skipUrlSync": false, 394 | "sort": 0, 395 | "type": "query" 396 | } 397 | ] 398 | }, 399 | "time": { 400 | "from": "now-5m", 401 | "to": "now" 402 | }, 403 | "timepicker": {}, 404 | "timezone": "", 405 | "title": "Polar Bookshop", 406 | "uid": "polar-http", 407 | "version": 1, 408 | "weekStart": "monday" 409 | } 410 | -------------------------------------------------------------------------------- /platform/grafana/dashboards/dashboards.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: dashboards 5 | orgId: 1 6 | type: file 7 | disableDeletion: true 8 | updateIntervalSeconds: 30 9 | allowUiUpdates: true 10 | options: 11 | path: /etc/grafana/provisioning/dashboards 12 | foldersFromFilesStructure: true 13 | -------------------------------------------------------------------------------- /platform/grafana/dashboards/platform/prometheus-stats.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": { 7 | "type": "grafana", 8 | "uid": "-- Grafana --" 9 | }, 10 | "enable": true, 11 | "hide": true, 12 | "iconColor": "rgba(0, 211, 255, 1)", 13 | "name": "Annotations & Alerts", 14 | "type": "dashboard" 15 | } 16 | ] 17 | }, 18 | "description": "The official, pre-built Prometheus Stats Dashboard.", 19 | "editable": true, 20 | "fiscalYearStartMonth": 0, 21 | "gnetId": 2, 22 | "graphTooltip": 0, 23 | "id": 6, 24 | "links": [ 25 | { 26 | "icon": "info", 27 | "tags": [], 28 | "targetBlank": true, 29 | "title": "Grafana Docs", 30 | "tooltip": "", 31 | "type": "link", 32 | "url": "http://www.grafana.org/docs" 33 | }, 34 | { 35 | "icon": "info", 36 | "tags": [], 37 | "targetBlank": true, 38 | "title": "Prometheus Docs", 39 | "type": "link", 40 | "url": "http://prometheus.io/docs/introduction/overview/" 41 | } 42 | ], 43 | "panels": [ 44 | { 45 | "datasource": { 46 | "type": "prometheus", 47 | "uid": "prometheus" 48 | }, 49 | "fieldConfig": { 50 | "defaults": { 51 | "color": { 52 | "mode": "thresholds" 53 | }, 54 | "decimals": 1, 55 | "mappings": [ 56 | { 57 | "options": { 58 | "match": "null", 59 | "result": { 60 | "text": "N/A" 61 | } 62 | }, 63 | "type": "special" 64 | } 65 | ], 66 | "thresholds": { 67 | "mode": "absolute", 68 | "steps": [ 69 | { 70 | "color": "green", 71 | "value": null 72 | }, 73 | { 74 | "color": "red", 75 | "value": 80 76 | } 77 | ] 78 | }, 79 | "unit": "s" 80 | }, 81 | "overrides": [] 82 | }, 83 | "gridPos": { 84 | "h": 5, 85 | "w": 6, 86 | "x": 0, 87 | "y": 0 88 | }, 89 | "id": 5, 90 | "maxDataPoints": 100, 91 | "options": { 92 | "colorMode": "none", 93 | "graphMode": "none", 94 | "justifyMode": "auto", 95 | "orientation": "horizontal", 96 | "reduceOptions": { 97 | "calcs": [ 98 | "lastNotNull" 99 | ], 100 | "fields": "", 101 | "values": false 102 | }, 103 | "showPercentChange": false, 104 | "textMode": "auto", 105 | "wideLayout": true 106 | }, 107 | "pluginVersion": "10.4.2", 108 | "targets": [ 109 | { 110 | "datasource": { 111 | "type": "prometheus", 112 | "uid": "prometheus" 113 | }, 114 | "expr": "(time() - process_start_time_seconds{job=\"prometheus\"})", 115 | "intervalFactor": 2, 116 | "refId": "A", 117 | "step": 4 118 | } 119 | ], 120 | "title": "Uptime", 121 | "type": "stat" 122 | }, 123 | { 124 | "datasource": { 125 | "type": "prometheus", 126 | "uid": "prometheus" 127 | }, 128 | "fieldConfig": { 129 | "defaults": { 130 | "color": { 131 | "fixedColor": "rgb(31, 120, 193)", 132 | "mode": "fixed" 133 | }, 134 | "mappings": [], 135 | "thresholds": { 136 | "mode": "absolute", 137 | "steps": [ 138 | { 139 | "color": "rgba(50, 172, 45, 0.97)", 140 | "value": null 141 | }, 142 | { 143 | "color": "rgba(237, 129, 40, 0.89)", 144 | "value": 1 145 | }, 146 | { 147 | "color": "rgba(245, 54, 54, 0.9)", 148 | "value": 5 149 | } 150 | ] 151 | }, 152 | "unit": "none" 153 | }, 154 | "overrides": [] 155 | }, 156 | "gridPos": { 157 | "h": 5, 158 | "w": 6, 159 | "x": 6, 160 | "y": 0 161 | }, 162 | "id": 6, 163 | "maxDataPoints": 100, 164 | "options": { 165 | "colorMode": "none", 166 | "graphMode": "area", 167 | "justifyMode": "auto", 168 | "orientation": "horizontal", 169 | "reduceOptions": { 170 | "calcs": [ 171 | "lastNotNull" 172 | ], 173 | "fields": "", 174 | "values": false 175 | }, 176 | "showPercentChange": false, 177 | "textMode": "auto", 178 | "wideLayout": true 179 | }, 180 | "pluginVersion": "10.4.2", 181 | "targets": [ 182 | { 183 | "datasource": { 184 | "type": "prometheus", 185 | "uid": "prometheus" 186 | }, 187 | "expr": "prometheus_local_storage_memory_series", 188 | "intervalFactor": 2, 189 | "refId": "A", 190 | "step": 4 191 | } 192 | ], 193 | "title": "Local Storage Memory Series", 194 | "type": "stat" 195 | }, 196 | { 197 | "datasource": { 198 | "type": "prometheus", 199 | "uid": "prometheus" 200 | }, 201 | "fieldConfig": { 202 | "defaults": { 203 | "color": { 204 | "mode": "thresholds" 205 | }, 206 | "mappings": [ 207 | { 208 | "options": { 209 | "0": { 210 | "text": "Empty" 211 | } 212 | }, 213 | "type": "value" 214 | } 215 | ], 216 | "thresholds": { 217 | "mode": "absolute", 218 | "steps": [ 219 | { 220 | "color": "rgba(50, 172, 45, 0.97)", 221 | "value": null 222 | }, 223 | { 224 | "color": "rgba(237, 129, 40, 0.89)", 225 | "value": 500 226 | }, 227 | { 228 | "color": "rgba(245, 54, 54, 0.9)", 229 | "value": 4000 230 | } 231 | ] 232 | }, 233 | "unit": "none" 234 | }, 235 | "overrides": [] 236 | }, 237 | "gridPos": { 238 | "h": 5, 239 | "w": 6, 240 | "x": 12, 241 | "y": 0 242 | }, 243 | "id": 7, 244 | "maxDataPoints": 100, 245 | "options": { 246 | "colorMode": "value", 247 | "graphMode": "area", 248 | "justifyMode": "auto", 249 | "orientation": "horizontal", 250 | "reduceOptions": { 251 | "calcs": [ 252 | "lastNotNull" 253 | ], 254 | "fields": "", 255 | "values": false 256 | }, 257 | "showPercentChange": false, 258 | "textMode": "auto", 259 | "wideLayout": true 260 | }, 261 | "pluginVersion": "10.4.2", 262 | "targets": [ 263 | { 264 | "datasource": { 265 | "type": "prometheus", 266 | "uid": "prometheus" 267 | }, 268 | "expr": "prometheus_local_storage_indexing_queue_length", 269 | "intervalFactor": 2, 270 | "refId": "A", 271 | "step": 4 272 | } 273 | ], 274 | "title": "Interal Storage Queue Length", 275 | "type": "stat" 276 | }, 277 | { 278 | "datasource": { 279 | "type": "loki", 280 | "uid": "loki" 281 | }, 282 | "editable": true, 283 | "error": false, 284 | "gridPos": { 285 | "h": 5, 286 | "w": 6, 287 | "x": 18, 288 | "y": 0 289 | }, 290 | "id": 9, 291 | "options": { 292 | "code": { 293 | "language": "plaintext", 294 | "showLineNumbers": false, 295 | "showMiniMap": false 296 | }, 297 | "content": "\"Prometheus\nPrometheus\n\n

You're using Prometheus, an open-source systems monitoring and alerting toolkit originally built at SoundCloud. For more information, check out the Grafana and Prometheus projects.

", 298 | "mode": "html" 299 | }, 300 | "pluginVersion": "10.4.2", 301 | "style": {}, 302 | "targets": [ 303 | { 304 | "datasource": { 305 | "type": "loki", 306 | "uid": "loki" 307 | }, 308 | "refId": "A" 309 | } 310 | ], 311 | "transparent": true, 312 | "type": "text" 313 | }, 314 | { 315 | "datasource": { 316 | "type": "prometheus", 317 | "uid": "prometheus" 318 | }, 319 | "fieldConfig": { 320 | "defaults": { 321 | "color": { 322 | "mode": "palette-classic" 323 | }, 324 | "custom": { 325 | "axisBorderShow": false, 326 | "axisCenteredZero": false, 327 | "axisColorMode": "text", 328 | "axisLabel": "", 329 | "axisPlacement": "auto", 330 | "barAlignment": 0, 331 | "drawStyle": "line", 332 | "fillOpacity": 10, 333 | "gradientMode": "none", 334 | "hideFrom": { 335 | "legend": false, 336 | "tooltip": false, 337 | "viz": false 338 | }, 339 | "insertNulls": false, 340 | "lineInterpolation": "linear", 341 | "lineWidth": 2, 342 | "pointSize": 5, 343 | "scaleDistribution": { 344 | "type": "linear" 345 | }, 346 | "showPoints": "never", 347 | "spanNulls": true, 348 | "stacking": { 349 | "group": "A", 350 | "mode": "none" 351 | }, 352 | "thresholdsStyle": { 353 | "mode": "off" 354 | } 355 | }, 356 | "mappings": [], 357 | "thresholds": { 358 | "mode": "absolute", 359 | "steps": [ 360 | { 361 | "color": "green", 362 | "value": null 363 | }, 364 | { 365 | "color": "red", 366 | "value": 80 367 | } 368 | ] 369 | }, 370 | "unit": "short" 371 | }, 372 | "overrides": [ 373 | { 374 | "matcher": { 375 | "id": "byName", 376 | "options": "prometheus" 377 | }, 378 | "properties": [ 379 | { 380 | "id": "color", 381 | "value": { 382 | "fixedColor": "#C15C17", 383 | "mode": "fixed" 384 | } 385 | } 386 | ] 387 | }, 388 | { 389 | "matcher": { 390 | "id": "byName", 391 | "options": "{instance=\"localhost:9090\",job=\"prometheus\"}" 392 | }, 393 | "properties": [ 394 | { 395 | "id": "color", 396 | "value": { 397 | "fixedColor": "#C15C17", 398 | "mode": "fixed" 399 | } 400 | } 401 | ] 402 | } 403 | ] 404 | }, 405 | "gridPos": { 406 | "h": 6, 407 | "w": 18, 408 | "x": 0, 409 | "y": 5 410 | }, 411 | "id": 3, 412 | "options": { 413 | "legend": { 414 | "calcs": [], 415 | "displayMode": "list", 416 | "placement": "bottom", 417 | "showLegend": true 418 | }, 419 | "tooltip": { 420 | "mode": "multi", 421 | "sort": "none" 422 | } 423 | }, 424 | "pluginVersion": "10.4.2", 425 | "targets": [ 426 | { 427 | "datasource": { 428 | "type": "prometheus", 429 | "uid": "prometheus" 430 | }, 431 | "expr": "rate(prometheus_local_storage_ingested_samples_total[5m])", 432 | "interval": "", 433 | "intervalFactor": 2, 434 | "legendFormat": "{{job}}", 435 | "metric": "", 436 | "refId": "A", 437 | "step": 2 438 | } 439 | ], 440 | "title": "Samples ingested (rate-5m)", 441 | "type": "timeseries" 442 | }, 443 | { 444 | "datasource": { 445 | "type": "loki", 446 | "uid": "loki" 447 | }, 448 | "editable": true, 449 | "error": false, 450 | "gridPos": { 451 | "h": 6, 452 | "w": 4, 453 | "x": 18, 454 | "y": 5 455 | }, 456 | "id": 8, 457 | "options": { 458 | "code": { 459 | "language": "plaintext", 460 | "showLineNumbers": false, 461 | "showMiniMap": false 462 | }, 463 | "content": "#### Samples Ingested\nThis graph displays the count of samples ingested by the Prometheus server, as measured over the last 5 minutes, per time series in the range vector. When troubleshooting an issue on IRC or Github, this is often the first stat requested by the Prometheus team. ", 464 | "mode": "markdown" 465 | }, 466 | "pluginVersion": "10.4.2", 467 | "style": {}, 468 | "targets": [ 469 | { 470 | "datasource": { 471 | "type": "loki", 472 | "uid": "loki" 473 | }, 474 | "refId": "A" 475 | } 476 | ], 477 | "transparent": true, 478 | "type": "text" 479 | }, 480 | { 481 | "datasource": { 482 | "type": "prometheus", 483 | "uid": "prometheus" 484 | }, 485 | "fieldConfig": { 486 | "defaults": { 487 | "color": { 488 | "mode": "palette-classic" 489 | }, 490 | "custom": { 491 | "axisBorderShow": false, 492 | "axisCenteredZero": false, 493 | "axisColorMode": "text", 494 | "axisLabel": "", 495 | "axisPlacement": "auto", 496 | "barAlignment": 0, 497 | "drawStyle": "line", 498 | "fillOpacity": 10, 499 | "gradientMode": "none", 500 | "hideFrom": { 501 | "legend": false, 502 | "tooltip": false, 503 | "viz": false 504 | }, 505 | "insertNulls": false, 506 | "lineInterpolation": "linear", 507 | "lineWidth": 2, 508 | "pointSize": 5, 509 | "scaleDistribution": { 510 | "type": "linear" 511 | }, 512 | "showPoints": "never", 513 | "spanNulls": true, 514 | "stacking": { 515 | "group": "A", 516 | "mode": "none" 517 | }, 518 | "thresholdsStyle": { 519 | "mode": "off" 520 | } 521 | }, 522 | "mappings": [], 523 | "thresholds": { 524 | "mode": "absolute", 525 | "steps": [ 526 | { 527 | "color": "green", 528 | "value": null 529 | }, 530 | { 531 | "color": "red", 532 | "value": 80 533 | } 534 | ] 535 | }, 536 | "unit": "short" 537 | }, 538 | "overrides": [ 539 | { 540 | "matcher": { 541 | "id": "byName", 542 | "options": "prometheus" 543 | }, 544 | "properties": [ 545 | { 546 | "id": "color", 547 | "value": { 548 | "fixedColor": "#F9BA8F", 549 | "mode": "fixed" 550 | } 551 | } 552 | ] 553 | }, 554 | { 555 | "matcher": { 556 | "id": "byName", 557 | "options": "{instance=\"localhost:9090\",interval=\"5s\",job=\"prometheus\"}" 558 | }, 559 | "properties": [ 560 | { 561 | "id": "color", 562 | "value": { 563 | "fixedColor": "#F9BA8F", 564 | "mode": "fixed" 565 | } 566 | } 567 | ] 568 | } 569 | ] 570 | }, 571 | "gridPos": { 572 | "h": 7, 573 | "w": 10, 574 | "x": 0, 575 | "y": 11 576 | }, 577 | "id": 2, 578 | "options": { 579 | "legend": { 580 | "calcs": [], 581 | "displayMode": "list", 582 | "placement": "bottom", 583 | "showLegend": true 584 | }, 585 | "tooltip": { 586 | "mode": "multi", 587 | "sort": "none" 588 | } 589 | }, 590 | "pluginVersion": "10.4.2", 591 | "targets": [ 592 | { 593 | "datasource": { 594 | "type": "prometheus", 595 | "uid": "prometheus" 596 | }, 597 | "expr": "rate(prometheus_target_interval_length_seconds_count[5m])", 598 | "intervalFactor": 2, 599 | "legendFormat": "{{job}}", 600 | "refId": "A", 601 | "step": 2 602 | } 603 | ], 604 | "title": "Target Scrapes (last 5m)", 605 | "type": "timeseries" 606 | }, 607 | { 608 | "datasource": { 609 | "type": "prometheus", 610 | "uid": "prometheus" 611 | }, 612 | "fieldConfig": { 613 | "defaults": { 614 | "color": { 615 | "mode": "palette-classic" 616 | }, 617 | "custom": { 618 | "axisBorderShow": false, 619 | "axisCenteredZero": false, 620 | "axisColorMode": "text", 621 | "axisLabel": "", 622 | "axisPlacement": "auto", 623 | "barAlignment": 0, 624 | "drawStyle": "line", 625 | "fillOpacity": 10, 626 | "gradientMode": "none", 627 | "hideFrom": { 628 | "legend": false, 629 | "tooltip": false, 630 | "viz": false 631 | }, 632 | "insertNulls": false, 633 | "lineInterpolation": "linear", 634 | "lineWidth": 2, 635 | "pointSize": 5, 636 | "scaleDistribution": { 637 | "type": "linear" 638 | }, 639 | "showPoints": "never", 640 | "spanNulls": true, 641 | "stacking": { 642 | "group": "A", 643 | "mode": "none" 644 | }, 645 | "thresholdsStyle": { 646 | "mode": "off" 647 | } 648 | }, 649 | "mappings": [], 650 | "thresholds": { 651 | "mode": "absolute", 652 | "steps": [ 653 | { 654 | "color": "green", 655 | "value": null 656 | }, 657 | { 658 | "color": "red", 659 | "value": 80 660 | } 661 | ] 662 | }, 663 | "unit": "short" 664 | }, 665 | "overrides": [] 666 | }, 667 | "gridPos": { 668 | "h": 7, 669 | "w": 8, 670 | "x": 10, 671 | "y": 11 672 | }, 673 | "id": 14, 674 | "options": { 675 | "legend": { 676 | "calcs": [], 677 | "displayMode": "list", 678 | "placement": "bottom", 679 | "showLegend": true 680 | }, 681 | "tooltip": { 682 | "mode": "multi", 683 | "sort": "none" 684 | } 685 | }, 686 | "pluginVersion": "10.4.2", 687 | "targets": [ 688 | { 689 | "datasource": { 690 | "type": "prometheus", 691 | "uid": "prometheus" 692 | }, 693 | "expr": "prometheus_target_interval_length_seconds{quantile!=\"0.01\", quantile!=\"0.05\"}", 694 | "interval": "", 695 | "intervalFactor": 2, 696 | "legendFormat": "{{quantile}} ({{interval}})", 697 | "metric": "", 698 | "refId": "A", 699 | "step": 2 700 | } 701 | ], 702 | "title": "Scrape Duration", 703 | "type": "timeseries" 704 | }, 705 | { 706 | "datasource": { 707 | "type": "loki", 708 | "uid": "loki" 709 | }, 710 | "editable": true, 711 | "error": false, 712 | "gridPos": { 713 | "h": 7, 714 | "w": 6, 715 | "x": 18, 716 | "y": 11 717 | }, 718 | "id": 11, 719 | "options": { 720 | "code": { 721 | "language": "plaintext", 722 | "showLineNumbers": false, 723 | "showMiniMap": false 724 | }, 725 | "content": "#### Scrapes\nPrometheus scrapes metrics from instrumented jobs, either directly or via an intermediary push gateway for short-lived jobs. Target scrapes will show how frequently targets are scraped, as measured over the last 5 minutes, per time series in the range vector. Scrape Duration will show how long the scrapes are taking, with percentiles available as series. ", 726 | "mode": "markdown" 727 | }, 728 | "pluginVersion": "10.4.2", 729 | "style": {}, 730 | "targets": [ 731 | { 732 | "datasource": { 733 | "type": "loki", 734 | "uid": "loki" 735 | }, 736 | "refId": "A" 737 | } 738 | ], 739 | "transparent": true, 740 | "type": "text" 741 | }, 742 | { 743 | "datasource": { 744 | "type": "prometheus", 745 | "uid": "prometheus" 746 | }, 747 | "fieldConfig": { 748 | "defaults": { 749 | "color": { 750 | "mode": "palette-classic" 751 | }, 752 | "custom": { 753 | "axisBorderShow": false, 754 | "axisCenteredZero": false, 755 | "axisColorMode": "text", 756 | "axisLabel": "", 757 | "axisPlacement": "auto", 758 | "barAlignment": 0, 759 | "drawStyle": "line", 760 | "fillOpacity": 10, 761 | "gradientMode": "none", 762 | "hideFrom": { 763 | "legend": false, 764 | "tooltip": false, 765 | "viz": false 766 | }, 767 | "insertNulls": false, 768 | "lineInterpolation": "linear", 769 | "lineWidth": 2, 770 | "pointSize": 5, 771 | "scaleDistribution": { 772 | "type": "linear" 773 | }, 774 | "showPoints": "never", 775 | "spanNulls": true, 776 | "stacking": { 777 | "group": "A", 778 | "mode": "none" 779 | }, 780 | "thresholdsStyle": { 781 | "mode": "off" 782 | } 783 | }, 784 | "mappings": [], 785 | "thresholds": { 786 | "mode": "absolute", 787 | "steps": [ 788 | { 789 | "color": "green", 790 | "value": null 791 | }, 792 | { 793 | "color": "red", 794 | "value": 80 795 | } 796 | ] 797 | }, 798 | "unit": "percentunit" 799 | }, 800 | "overrides": [ 801 | { 802 | "matcher": { 803 | "id": "byValue", 804 | "options": { 805 | "op": "gte", 806 | "reducer": "allIsNull", 807 | "value": 0 808 | } 809 | }, 810 | "properties": [ 811 | { 812 | "id": "custom.hideFrom", 813 | "value": { 814 | "legend": true, 815 | "tooltip": true, 816 | "viz": false 817 | } 818 | } 819 | ] 820 | } 821 | ] 822 | }, 823 | "gridPos": { 824 | "h": 7, 825 | "w": 18, 826 | "x": 0, 827 | "y": 18 828 | }, 829 | "id": 12, 830 | "options": { 831 | "legend": { 832 | "calcs": [], 833 | "displayMode": "list", 834 | "placement": "bottom", 835 | "showLegend": true 836 | }, 837 | "tooltip": { 838 | "mode": "multi", 839 | "sort": "none" 840 | } 841 | }, 842 | "pluginVersion": "10.4.2", 843 | "targets": [ 844 | { 845 | "datasource": { 846 | "type": "prometheus", 847 | "uid": "prometheus" 848 | }, 849 | "expr": "prometheus_evaluator_duration_milliseconds{quantile!=\"0.01\", quantile!=\"0.05\"}", 850 | "interval": "", 851 | "intervalFactor": 2, 852 | "legendFormat": "{{quantile}}", 853 | "refId": "A", 854 | "step": 2 855 | } 856 | ], 857 | "title": "Rule Eval Duration", 858 | "type": "timeseries" 859 | }, 860 | { 861 | "datasource": { 862 | "type": "loki", 863 | "uid": "loki" 864 | }, 865 | "editable": true, 866 | "error": false, 867 | "gridPos": { 868 | "h": 7, 869 | "w": 6, 870 | "x": 18, 871 | "y": 18 872 | }, 873 | "id": 15, 874 | "options": { 875 | "code": { 876 | "language": "plaintext", 877 | "showLineNumbers": false, 878 | "showMiniMap": false 879 | }, 880 | "content": "#### Rule Evaluation Duration\nThis graph panel plots the duration for all evaluations to execute. The 50th percentile, 90th percentile and 99th percentile are shown as three separate series to help identify outliers that may be skewing the data.", 881 | "mode": "markdown" 882 | }, 883 | "pluginVersion": "10.4.2", 884 | "style": {}, 885 | "targets": [ 886 | { 887 | "datasource": { 888 | "type": "loki", 889 | "uid": "loki" 890 | }, 891 | "refId": "A" 892 | } 893 | ], 894 | "transparent": true, 895 | "type": "text" 896 | } 897 | ], 898 | "refresh": "5s", 899 | "schemaVersion": 39, 900 | "tags": [], 901 | "templating": { 902 | "list": [] 903 | }, 904 | "time": { 905 | "from": "now-5m", 906 | "to": "now" 907 | }, 908 | "timepicker": { 909 | "now": true, 910 | "refresh_intervals": [ 911 | "5s", 912 | "10s", 913 | "30s", 914 | "1m", 915 | "5m", 916 | "15m", 917 | "30m", 918 | "1h", 919 | "2h", 920 | "1d" 921 | ], 922 | "time_options": [ 923 | "5m", 924 | "15m", 925 | "1h", 926 | "6h", 927 | "12h", 928 | "24h", 929 | "2d", 930 | "7d", 931 | "30d" 932 | ] 933 | }, 934 | "timezone": "browser", 935 | "title": "Prometheus Stats", 936 | "uid": "fdj0otgec5uyob", 937 | "version": 2, 938 | "weekStart": "" 939 | } 940 | -------------------------------------------------------------------------------- /platform/grafana/datasources/datasources.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | deleteDatasources: 4 | - name: Prometheus 5 | - name: Tempo 6 | - name: Loki 7 | 8 | datasources: 9 | - name: Prometheus 10 | type: prometheus 11 | uid: prometheus 12 | access: proxy 13 | url: http://prometheus:9090 14 | basicAuth: false 15 | isDefault: false 16 | version: 1 17 | editable: true 18 | jsonData: 19 | httpMethod: POST 20 | exemplarTraceIdDestinations: 21 | - name: traceID 22 | datasourceUid: tempo 23 | - name: Tempo 24 | type: tempo 25 | uid: tempo 26 | access: proxy 27 | url: http://tempo:3100 28 | basicAuth: false 29 | isDefault: false 30 | version: 1 31 | editable: true 32 | jsonData: 33 | httpMethod: GET 34 | tracesToMetrics: 35 | datasourceUid: prometheus 36 | tags: [ { key: 'service.name', value: 'application' }, { key: 'org' }, { key: 'method' }, { key: 'uri' }, { key: 'outcome' }, { key: 'status' }, { key: 'exception' } ] 37 | queries: 38 | - name: 'Throughput' 39 | query: 'sum(rate(http_server_requests_seconds_count{$$__tags}[$$__rate_interval]))' 40 | - name: 'Latency' 41 | query: 'histogram_quantile(1.00, sum(rate(http_server_requests_seconds_bucket{$$__tags}[$$__rate_interval])) by (le))' 42 | tracesToLogs: 43 | datasourceUid: 'loki' 44 | tags: [ 'instance', 'pod', 'namespace', 'hostname' ] 45 | mappedTags: [ { key: 'org' }, { key: 'service.name', value: 'application' } ] 46 | mapTagNamesEnabled: true 47 | spanStartTimeShift: '1h' 48 | spanEndTimeShift: '1h' 49 | filterByTraceID: true 50 | filterBySpanID: false 51 | lokiSearch: true 52 | lokiSearch: 53 | datasourceUid: loki 54 | serviceMap: 55 | datasourceUid: prometheus 56 | search: 57 | hide: false 58 | nodeGraph: 59 | enabled: true 60 | - name: Loki 61 | type: loki 62 | uid: loki 63 | access: proxy 64 | url: http://loki:3100 65 | basicAuth: false 66 | isDefault: true 67 | version: 1 68 | editable: true 69 | jsonData: 70 | derivedFields: 71 | - datasourceUid: tempo 72 | matcherRegex: '.+ --- \[.+\] \[.+\] \[(\w*)-\w*\] .+' 73 | name: traceId 74 | url: $${__value.raw} 75 | -------------------------------------------------------------------------------- /platform/grafana/grafana.ini: -------------------------------------------------------------------------------- 1 | [paths] 2 | temp_data_lifetime = 1h 3 | 4 | [server] 5 | root_url = http://${HOSTNAME}:3000 6 | serve_from_sub_path = false 7 | enable_gzip = true 8 | read_timeout = 60s 9 | 10 | [database] 11 | type = postgres 12 | host = postgres:5432 13 | name = grafana 14 | instrument_queries = true 15 | 16 | [remote_cache] 17 | type = database 18 | 19 | [analytics] 20 | enabled = false 21 | reporting_enabled = false 22 | check_for_updates = false 23 | check_for_plugin_updates = false 24 | feedback_links_enabled = false 25 | 26 | [security] 27 | secret_key = SW2YcwTIb9zpOOhoPsMm 28 | disable_gravatar = true 29 | content_security_policy = true 30 | content_security_policy_report_only = true 31 | csrf_always_check = true 32 | 33 | [snapshots] 34 | enabled = false 35 | external_enabled = false 36 | 37 | [dashboards] 38 | versions_to_keep = 10 39 | 40 | [users] 41 | default_theme = light 42 | hidden_users = ${GF_SECURITY_ADMIN_USER} 43 | 44 | [log] 45 | level = info 46 | mode = console 47 | 48 | [news] 49 | news_feed_enabled = false 50 | 51 | [tracing.opentelemetry] 52 | sampler_type = probabilistic 53 | sampler_param = 0.1 54 | 55 | [tracing.opentelemetry.otlp] 56 | address = tempo:4317 57 | propagation = w3c 58 | 59 | [feature_toggles] 60 | enable = correlations dataSourcePageHeader traceToMetrics scenes showDashboardValidationWarnings extraThemes lokiPredefinedOperations lokiFormatQuery exploreScrollableLogsContainer vizAndWidgetSplit logsExploreTableVisualisation metricsSummary featureToggleAdminPage httpSLOLevels enableNativeHTTPHistogram 61 | -------------------------------------------------------------------------------- /platform/loki/loki.yml: -------------------------------------------------------------------------------- 1 | auth_enabled: false 2 | 3 | server: 4 | http_listen_port: 3100 5 | 6 | common: 7 | path_prefix: /loki 8 | storage: 9 | filesystem: 10 | chunks_directory: /loki/chunks 11 | rules_directory: /loki/rules 12 | replication_factor: 1 13 | ring: 14 | kvstore: 15 | store: inmemory 16 | 17 | schema_config: 18 | configs: 19 | - from: 2020-10-24 20 | store: boltdb-shipper 21 | object_store: filesystem 22 | schema: v11 23 | index: 24 | prefix: index_ 25 | period: 24h 26 | 27 | query_scheduler: 28 | max_outstanding_requests_per_tenant: 2048 29 | 30 | analytics: 31 | reporting_enabled: false 32 | -------------------------------------------------------------------------------- /platform/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 5s 3 | 4 | scrape_configs: 5 | - job_name: 'prometheus' 6 | static_configs: 7 | - targets: ['prometheus:9090'] 8 | -------------------------------------------------------------------------------- /platform/tempo/tempo.yml: -------------------------------------------------------------------------------- 1 | server: 2 | http_listen_port: 3100 3 | 4 | distributor: 5 | receivers: 6 | otlp: 7 | protocols: 8 | grpc: 9 | http: 10 | jaeger: 11 | protocols: 12 | thrift_http: 13 | grpc: 14 | thrift_binary: 15 | thrift_compact: 16 | zipkin: 17 | 18 | metrics_generator: 19 | registry: 20 | external_labels: 21 | source: tempo 22 | storage: 23 | path: /tmp/tempo/generator/wal 24 | remote_write: 25 | - url: http://prometheus:9090/api/v1/write 26 | send_exemplars: true 27 | 28 | storage: 29 | trace: 30 | backend: local 31 | local: 32 | path: /tmp/tempo/blocks 33 | 34 | overrides: 35 | metrics_generator_processors: [service-graphs, span-metrics] 36 | 37 | usage_report: 38 | reporting_enabled: false 39 | --------------------------------------------------------------------------------