├── .gitattributes ├── .github └── workflows │ ├── pages.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── Makefile ├── README.md ├── client ├── .gitignore ├── .prettierrc ├── .vscode │ └── extensions.json ├── README.md ├── index.html ├── jest.config.js ├── jest.setup.ts ├── package.json ├── roots │ └── mermaid.html ├── src │ ├── App.svelte │ ├── Mermaid.svelte │ ├── app.css │ ├── components │ │ ├── DetailsCard.svelte │ │ ├── ExaminationResultTable.svelte │ │ ├── ExplainText.svelte │ │ ├── ExplainTree │ │ │ ├── ExplainTree.ts │ │ │ └── ExplainTreeChart.ts │ │ ├── IndexSuggestion.svelte │ │ └── PlainText.svelte │ ├── contexts │ │ └── HighlightIndexContext.ts │ ├── dummies │ │ ├── DummyDigData.ts │ │ ├── DummySuggestData.ts │ │ ├── analyzeHasuraPostgresNodes.json │ │ ├── analyzeMysqlNodes.json │ │ ├── analyzePostgresNodes.json │ │ ├── digSqlDbLoads.json │ │ ├── digTokenizedSqlDbLoads.json │ │ ├── hasuraPostgresQuery.sql │ │ └── jaegerTraces.json │ ├── dummy.ts │ ├── lib │ │ ├── GrChart.ts │ │ └── components │ │ │ └── CommandCode.svelte │ ├── main.ts │ ├── mermaid.ts │ ├── models │ │ ├── BaseSuggestData.ts │ │ ├── DbLoad.ts │ │ ├── DbLoadOfSql.ts │ │ ├── DigData.ts │ │ ├── ExaminationCommandOption.ts │ │ ├── ExaminationIndexResult.ts │ │ ├── ExaminationResult.ts │ │ ├── HasuraIndexTarget.ts │ │ ├── HasuraSuggestData.ts │ │ ├── IndexColumn.ts │ │ ├── IndexTarget.ts │ │ ├── JaegerData.ts │ │ ├── MysqlSuggestData.ts │ │ ├── OtelCompactedTrace.ts │ │ ├── OtelTraceSpan.ts │ │ ├── PerformanceInsightsData.ts │ │ ├── PostgresIndexTarget.ts │ │ ├── PostgresSuggestData.ts │ │ ├── SuggestData.ts │ │ ├── TimeDbLoad.ts │ │ ├── explain_data │ │ │ ├── DbAnalyzeData.ts │ │ │ ├── DbExplainData.ts │ │ │ ├── MysqlAnalyzeData.ts │ │ │ ├── PostgresAnalyzeData.ts │ │ │ └── PostgresExplainData.ts │ │ └── trace_diagram │ │ │ ├── ITraceNode.ts │ │ │ ├── TraceNode.ts │ │ │ ├── TraceRepeatNode.ts │ │ │ └── TraceTree.ts │ ├── pages │ │ ├── dig │ │ │ ├── jaeger │ │ │ │ ├── DigJaegerPage.svelte │ │ │ │ ├── TraceDiagram.svelte │ │ │ │ └── TracesTable.svelte │ │ │ └── performance_insights │ │ │ │ ├── DbLoadChart.ts │ │ │ │ ├── DbLoadRankingChart.ts │ │ │ │ ├── DbTokenizedLoadChart.ts │ │ │ │ ├── DbTokenizedLoadRankingChart.ts │ │ │ │ ├── DigPerformanceInsightsPage.svelte │ │ │ │ ├── HeaviestSqlRanking.svelte │ │ │ │ ├── HeaviestTokenizedSqlRanking.svelte │ │ │ │ ├── HeavySqlTimeline.svelte │ │ │ │ ├── HeavyTokenizedSqlTimeline.svelte │ │ │ │ └── util │ │ │ │ ├── CommandText.ts │ │ │ │ ├── Databases.ts │ │ │ │ ├── LoadTooltip.ts │ │ │ │ └── chart │ │ │ │ ├── LoadPropChartSereies.ts │ │ │ │ └── TokenizedLoad.ts │ │ └── suggest │ │ │ ├── hasura │ │ │ ├── ExaminationLineMenu.svelte │ │ │ ├── ExplainChart.svelte │ │ │ └── SuggestHasuraPage.svelte │ │ │ ├── mysql │ │ │ ├── ExplainAnalyzeChart.svelte │ │ │ └── SuggestMysqlPage.svelte │ │ │ └── postgres │ │ │ ├── ExplainAnalyzeChart.svelte │ │ │ └── SuggestPostgresPage.svelte │ ├── stores │ │ └── gr_param.ts │ ├── types │ │ ├── apexcharts.d.ts │ │ ├── gr_param.d.ts │ │ ├── svelte-chota.d.ts │ │ └── window.d.ts │ └── vite-env.d.ts ├── svelte.config.js ├── test │ └── src │ │ ├── lib │ │ └── ExplainAnalyzeTree.test.ts │ │ └── pages │ │ ├── dig │ │ ├── jaeger │ │ │ └── DigJaegerPage.test.ts │ │ └── performance_insights │ │ │ └── DigPerformanceInsightsPage.test.ts │ │ └── suggest │ │ ├── hasura │ │ └── SuggestHasuraPage.test.ts │ │ ├── mysql │ │ └── SuggestMysqlPage.test.ts │ │ └── postgres │ │ └── SuggestPostgresPage.test.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite-common.config.ts ├── vite-main.config.ts ├── vite-mermaid.config.ts ├── vite.config.ts └── yarn.lock ├── cmd ├── app.go ├── app │ ├── dig.go │ └── dig │ │ ├── jaeger.go │ │ └── jaeger_test.go ├── db.go ├── db │ ├── dig.go │ ├── dig │ │ ├── performance_insights.go │ │ └── performance_insights_test.go │ ├── suggest.go │ └── suggest │ │ ├── hasura.go │ │ ├── hasura │ │ ├── postgres.go │ │ └── postgres_test.go │ │ ├── mysql.go │ │ ├── mysql_test.go │ │ ├── postgres.go │ │ └── postgres_test.go ├── flag │ ├── app_flag.go │ ├── db_flag.go │ └── global_flag.go ├── root.go ├── util │ └── log.go └── version.go ├── database ├── dmodel │ ├── examination_index_result.go │ ├── examination_result.go │ ├── index_column.go │ ├── index_target.go │ ├── index_target_test.go │ └── single_table_explain_result.go ├── dservice │ ├── build_single_table_explain_results.go │ ├── build_single_table_explain_results_test.go │ ├── dparser │ │ ├── build_table_fields.go │ │ ├── column_schema.go │ │ ├── field.go │ │ ├── field_column.go │ │ ├── field_type.go │ │ ├── index_field.go │ │ ├── index_target_builder.go │ │ ├── index_target_builder_test.go │ │ ├── index_target_table.go │ │ ├── stmt_scope.go │ │ ├── table.go │ │ └── table_schema.go │ ├── examiner.go │ └── existing_index_remover.go ├── hasura │ └── hservice │ │ ├── index_examiner.go │ │ ├── index_getter.go │ │ ├── index_suggester.go │ │ └── parser │ │ ├── collect_table_schemas.go │ │ └── index.go ├── mysql │ ├── mmodel │ │ ├── analyze_result_line.go │ │ ├── explain_analyze_result_line.go │ │ ├── explain_analyze_result_line_test.go │ │ ├── explain_analyze_tree.go │ │ └── explain_analyze_tree_node.go │ └── mservice │ │ ├── explainer.go │ │ ├── explainer_test.go │ │ ├── index_examiner.go │ │ ├── index_getter.go │ │ ├── index_suggester.go │ │ ├── parse.go │ │ └── parser │ │ ├── collect_stmt_scopes.go │ │ ├── collect_stmt_scopes_test.go │ │ ├── collect_table_names.go │ │ ├── collect_table_names_test.go │ │ ├── collect_table_schemas.go │ │ ├── collect_table_schemas_test.go │ │ ├── errors.go │ │ └── index.go └── postgres │ ├── pmodel │ ├── explain_analyze_result_node.go │ ├── explain_analyze_result_node_test.go │ ├── explain_analyze_tree.go │ └── explain_analyze_tree_node.go │ └── pservice │ ├── ast │ └── ast_walker.go │ ├── explain_analyze_tree_builder.go │ ├── explain_analyze_tree_builder_test.go │ ├── explainer.go │ ├── explainer_test.go │ ├── index_examiner.go │ ├── index_getter.go │ ├── index_suggester.go │ ├── parse.go │ └── parser │ ├── collect_stmt_scopes.go │ ├── collect_stmt_scopes_test.go │ ├── collect_table_names.go │ ├── collect_table_names_test.go │ ├── collect_table_schemas.go │ ├── collect_table_schemas_test.go │ └── index.go ├── docker-compose.yml ├── docker ├── aws_mock │ ├── Dockerfile │ └── fastapi │ │ ├── .gitignore │ │ ├── data │ │ ├── pi │ │ │ ├── GetResourceMetrics_data.json │ │ │ └── GetResourceMetrics_tokenized_data.json │ │ └── rds │ │ │ └── DescribeDbInstances.xml │ │ ├── main.py │ │ ├── pi.py │ │ ├── rds.py │ │ └── requirements.txt ├── go.mod ├── jaeger_mock │ ├── Dockerfile │ └── mock │ │ ├── dumper.go │ │ ├── go.mod │ │ ├── go.sum │ │ ├── main.go │ │ ├── mock_dependency_reader.go │ │ ├── mock_span_reader.go │ │ ├── mockdata │ │ ├── 01_simple_trace.json │ │ ├── 02_slow_trace.json │ │ ├── 03_labs_trace.json │ │ ├── 04_slow_trace_no_service.json │ │ ├── 05_multiple_access.json │ │ └── 06_same_db_system.json │ │ └── trace.go ├── mysql8 │ └── init │ │ └── script.sql ├── node │ ├── Dockerfile │ └── hasura │ │ ├── config.yaml │ │ └── metadata │ │ ├── actions.graphql │ │ ├── actions.yaml │ │ ├── allow_list.yaml │ │ ├── api_limits.yaml │ │ ├── cron_triggers.yaml │ │ ├── databases │ │ ├── databases.yaml │ │ └── default │ │ │ └── tables │ │ │ ├── public_tasks.yaml │ │ │ ├── public_users.yaml │ │ │ └── tables.yaml │ │ ├── graphql_schema_introspection.yaml │ │ ├── inherited_roles.yaml │ │ ├── network.yaml │ │ ├── query_collections.yaml │ │ ├── remote_schemas.yaml │ │ ├── rest_endpoints.yaml │ │ └── version.yaml └── postgres14 │ └── init │ └── script.sql ├── docs └── images │ ├── jaeger.png │ └── postgres.png ├── go.mod ├── go.sum ├── html ├── build_option.go ├── html_builder.go └── viewmodel │ ├── vm_db_load.go │ ├── vm_db_load_of_sql.go │ ├── vm_examination_command_option.go │ ├── vm_examination_index_result.go │ ├── vm_examination_result.go │ ├── vm_index_column.go │ ├── vm_index_target.go │ ├── vm_mysql_explain_analyze_node.go │ ├── vm_otel_compact_trace.go │ ├── vm_otel_trace_span.go │ ├── vm_postgres_explain_analyze_node.go │ └── vm_time_db_load.go ├── infra ├── aws │ ├── aws.go │ ├── performance_insights.go │ ├── performance_insights_test.go │ ├── pi_sql_load_avg.go │ ├── pi_sql_load_avg_test.go │ ├── pi_time_value.go │ ├── rds.go │ ├── rds_db.go │ └── rds_test.go ├── hasura │ ├── client.go │ ├── column_info.go │ ├── config.go │ ├── explain_request_body.go │ ├── explain_response.go │ ├── index_info.go │ ├── query.go │ ├── run_sql_args.go │ └── run_sql_response.go ├── jaeger │ ├── client.go │ ├── client_test.go │ └── trace.go ├── mysql │ ├── column_info.go │ ├── config.go │ ├── db.go │ ├── db_test.go │ └── index_info.go ├── postgres │ ├── column_info.go │ ├── config.go │ ├── db.go │ ├── db_test.go │ └── index_info.go └── rdb │ └── db.go ├── injection ├── bin_info.go └── client_dist.go ├── lab └── micro │ ├── .gitignore │ ├── bff │ ├── Dockerfile │ ├── package.json │ ├── server.js │ ├── tel.js │ └── yarn.lock │ ├── docker-compose.yml │ ├── docker │ └── user_db_mysql │ │ └── init │ │ └── script.sql │ └── user │ ├── Dockerfile │ ├── db.go │ ├── go.mod │ ├── go.sum │ ├── main.go │ └── otel.go ├── lib ├── env.go ├── env_test.go ├── errors.go ├── heap.go ├── heap_test.go ├── http_transport.go ├── join.go ├── join_test.go ├── ptr.go ├── set.go ├── set_test.go ├── slice.go ├── slice_test.go ├── sort.go ├── sort_test.go ├── stack.go ├── stack_test.go ├── time.go ├── time_test.go ├── uniq.go └── uniq_test.go ├── main.go ├── mocks └── jaeger │ └── Reader.go ├── otel ├── jaeger │ ├── trace_fetcher.go │ ├── trace_fetcher_test.go │ ├── trace_picker.go │ ├── trace_picker_test.go │ ├── v1_converter.go │ └── v1_converter_test.go └── omodel │ ├── any_value.go │ ├── compact_info_collect_visitor.go │ ├── same_resource_count_visitor.go │ ├── span.go │ ├── span_test.go │ ├── trace_compacter.go │ ├── trace_tree.go │ ├── trace_tree_test.go │ └── trace_visitor.go ├── testdata └── files │ └── aws │ ├── pi │ ├── GetResourceMetrics_sql.json │ └── GetResourceMetrics_tokenized_sql.json │ └── rds │ └── DescribeDbInstances.xml └── thelper ├── bytes.go ├── db.go ├── file.go ├── injection.go ├── jaeger.go ├── setup.go ├── tdata ├── hasura_data.go └── postgres_data.go └── temp.go /.gitattributes: -------------------------------------------------------------------------------- 1 | example/* linguist-generated 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | on: [push] 3 | jobs: 4 | go-test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - uses: actions/setup-go@v3 9 | with: 10 | go-version-file: 'go.mod' 11 | cache: true 12 | - name: 'Create a dummy file to enable to build' 13 | run: mkdir -p client/dist/assets && touch client/dist/assets/.dummy 14 | - uses: golangci/golangci-lint-action@v3 15 | with: 16 | args: --timeout 5m --verbose 17 | - run: make test 18 | client-test: 19 | defaults: 20 | run: 21 | working-directory: client 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | - uses: actions/setup-node@v3 26 | with: 27 | node-version: 16 28 | cache: 'yarn' 29 | cache-dependency-path: 'client/yarn.lock' 30 | - run: yarn install 31 | - run: yarn check:all 32 | - run: yarn test 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Go template 2 | # Binaries for programs and plugins 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | 9 | # Test binary, built with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | # Dependency directories (remove the comment below to include it) 16 | # vendor/ 17 | 18 | docker/*/data 19 | dist/ 20 | example/ 21 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # Make sure to check the documentation at https://goreleaser.com 2 | project_name: GravityR 3 | dist: dist/goreleaser 4 | builds: 5 | - id: gr 6 | binary: gr 7 | env: 8 | - CGO_ENABLED=0 9 | goos: 10 | - linux 11 | - windows 12 | - darwin 13 | goarch: 14 | - amd64 15 | - arm64 16 | archives: 17 | - format: binary 18 | replacements: 19 | darwin: Darwin 20 | linux: Linux 21 | windows: Windows 22 | amd64: x86_64 23 | checksum: 24 | name_template: 'checksums.txt' 25 | snapshot: 26 | name_template: "{{ incpatch .Version }}-next" 27 | # changelog: 28 | # sort: asc 29 | # filters: 30 | # exclude: 31 | # - '^docs:' 32 | # - '^test:' 33 | 34 | # modelines, feel free to remove those if you don't want/use them: 35 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 36 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 mrasu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": ["src/dummies/*.json"], 5 | "options": { 6 | "printWidth": 1000 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /client/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["svelte.svelte-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GravityR 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "rm -rf ./dist && vite build -c vite-main.config.ts && vite build -c vite-mermaid.config.ts", 9 | "preview": "vite preview", 10 | "check:all": "yarn check:svelte && yarn check:prettier", 11 | "check:svelte": "svelte-check --fail-on-warnings --fail-on-hints --tsconfig ./tsconfig.json", 12 | "check:prettier": "prettier --check --ignore-path .gitignore \"!package-lock.json\" --plugin-search-dir=. ./**/*.{json,css,js,ts,cjs,svelte}", 13 | "test": "jest", 14 | "fix:prettier": "prettier --write --ignore-path .gitignore \"!package-lock.json\" --plugin-search-dir=. ./**/*.{json,css,js,ts,cjs,svelte}" 15 | }, 16 | "devDependencies": { 17 | "@sveltejs/vite-plugin-svelte": "^1.0.1", 18 | "@testing-library/jest-dom": "^5.16.5", 19 | "@testing-library/svelte": "^3.2.1", 20 | "@tsconfig/svelte": "^3.0.0", 21 | "jest-environment-jsdom": "^29.0.3", 22 | "svelte": "^3.49.0", 23 | "svelte-check": "^2.8.0", 24 | "svelte-jester": "^2.3.2", 25 | "svelte-preprocess": "^4.10.7", 26 | "tslib": "^2.4.0", 27 | "typescript": "^4.6.4", 28 | "vite": "^3.0.0" 29 | }, 30 | "dependencies": { 31 | "@types/jest": "^28.1.6", 32 | "apexcharts": "^3.35.3", 33 | "chota": "^0.8.0", 34 | "class-transformer": "^0.5.1", 35 | "jest": "^28.1.3", 36 | "mermaid": "^9.2.2", 37 | "prettier": "^2.7.1", 38 | "prettier-plugin-svelte": "^2.7.0", 39 | "reflect-metadata": "^0.1.13", 40 | "sql-formatter": "^10.6.0", 41 | "svelte-chota": "^1.8.6", 42 | "ts-jest": "^28.0.7", 43 | "ts-node": "^10.9.1" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client/roots/mermaid.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GravityR 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/src/Mermaid.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 |
24 | {#if $grParam.dev} 25 | 32 | {/if} 33 | 34 |

GravityR

35 | 36 | {#if page === "dig_jaeger"} 37 | 38 | {/if} 39 |
40 | -------------------------------------------------------------------------------- /client/src/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | display: flex; 4 | min-width: 320px; 5 | min-height: 100vh; 6 | } 7 | 8 | h1 { 9 | font-size: 3.2em; 10 | line-height: 1.1; 11 | } 12 | 13 | #app { 14 | width: 1280px; 15 | margin: 0 auto; 16 | padding: 2rem; 17 | } 18 | -------------------------------------------------------------------------------- /client/src/components/DetailsCard.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 |
11 | {title} 12 | 13 |
14 |
15 | 16 | 21 | -------------------------------------------------------------------------------- /client/src/components/ExaminationResultTable.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
Times after adding index
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 27 | 28 | 29 | 31 | 32 | {#each sortedIndexResults as indexResult} 33 | {@const reducedPercent = indexResult.toReductionPercent( 34 | result.originalTimeMillis 35 | )} 36 | 37 | 40 | 41 | 42 | 46 | 47 | {/each} 48 |
Execution Time
Reduction
Columns
SQL
24 | {result.originalTimeMillis.toLocaleString()}ms 25 |
-
(Original)
30 |
38 |
{indexResult.executionTimeMillis.toLocaleString()}ms
{reducedPercent}%
{indexResult.toIndex()} 43 | {indexResult.indexTarget.toAlterAddSQL()} 44 | 45 |
49 | 50 | 74 | -------------------------------------------------------------------------------- /client/src/components/ExplainText.svelte: -------------------------------------------------------------------------------- 1 | 32 | 33 |
34 | {#each texts as text, i} 35 | {`${text}`}
36 | {/each} 37 | {#if trailingText.length > 0} 38 | -----------
39 | {trailingText} 40 | {/if} 41 |
42 | 43 | 55 | -------------------------------------------------------------------------------- /client/src/components/IndexSuggestion.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 |
Adding index to below columns might improve performance of the query.
43 | 44 | 53 | 54 |
55 | With below command, you can examine the efficiency of indexes by temporarily 56 | adding them in your database. 57 |
58 | 59 |
60 | $ {commandText} 61 |
62 | 63 | 75 | -------------------------------------------------------------------------------- /client/src/components/PlainText.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
{text.trim()}
6 | 7 | 15 | -------------------------------------------------------------------------------- /client/src/contexts/HighlightIndexContext.ts: -------------------------------------------------------------------------------- 1 | import { getContext, setContext } from "svelte"; 2 | import { writable } from "svelte/store"; 3 | import type { Writable } from "svelte/types/runtime/store"; 4 | 5 | type numberOrUndef = number | undefined; 6 | 7 | export function createHighlightIndexContext() { 8 | const symbol = Symbol(); 9 | const x = writable(undefined); 10 | setContext>(symbol, x); 11 | 12 | return symbol; 13 | } 14 | 15 | export function getHighlightIndex(symbol: symbol): Writable { 16 | return getContext>(symbol); 17 | } 18 | -------------------------------------------------------------------------------- /client/src/dummies/DummyDigData.ts: -------------------------------------------------------------------------------- 1 | import type { IDbLoad, ITimeDbLoad } from "../types/gr_param"; 2 | import digSqlDbLoads from "./digSqlDbLoads.json"; 3 | import digTokenizedSqlDbLoads from "./digTokenizedSqlDbLoads.json"; 4 | import jaegerTraces from "./jaegerTraces.json"; 5 | 6 | type timeDbLoad = { timestamp: string; databases: IDbLoad[] }; 7 | 8 | const toTimeDbLoads = (loads: timeDbLoad[]) => { 9 | const timeDbLoads: ITimeDbLoad[] = []; 10 | for (const sql of loads) { 11 | timeDbLoads.push({ 12 | ...sql, 13 | timestamp: new Date(sql.timestamp).getTime(), 14 | }); 15 | } 16 | return timeDbLoads; 17 | }; 18 | 19 | export const dummyDigData = { 20 | performanceInsights: { 21 | sqlDbLoads: toTimeDbLoads(digSqlDbLoads), 22 | tokenizedSqlDbLoads: toTimeDbLoads(digTokenizedSqlDbLoads), 23 | }, 24 | jaeger: jaegerTraces, 25 | }; 26 | -------------------------------------------------------------------------------- /client/src/dummies/hasuraPostgresQuery.sql: -------------------------------------------------------------------------------- 1 | SELECT coalesce(json_agg("root" ), '[]' ) AS "root" FROM (SELECT row_to_json((SELECT "_e" FROM (SELECT "_root.or.user"."user" AS "user", "_root.base"."description" AS "description", "_root.base"."id" AS "id", "_root.base"."status" AS "status" ) AS "_e" ) ) AS "root" FROM (SELECT * FROM "public"."tasks" WHERE (EXISTS (SELECT 1 FROM "public"."users" AS "__be_0_users" WHERE (((("__be_0_users"."id") = ("public"."tasks"."user_id")) AND ('true')) AND (('true') AND ((((("__be_0_users"."email") = (('test1111@example.com')::varchar)) AND ('true')) AND ('true')) AND ('true')))) )) ) AS "_root.base" LEFT OUTER JOIN LATERAL (SELECT row_to_json((SELECT "_e" FROM (SELECT "_root.or.user.base"."email" AS "email", "_root.or.user.base"."id" AS "id", "_root.or.user.base"."name" AS "name" ) AS "_e" ) ) AS "user" FROM (SELECT * FROM "public"."users" WHERE (("_root.base"."user_id") = ("id")) LIMIT 1 ) AS "_root.or.user.base" ) AS "_root.or.user" ON ('true') ) AS "_root" 2 | -------------------------------------------------------------------------------- /client/src/dummy.ts: -------------------------------------------------------------------------------- 1 | import { dummySuggestData } from "./dummies/DummySuggestData"; 2 | import { dummyDigData } from "./dummies/DummyDigData"; 3 | 4 | window.grParam = { 5 | dev: true, 6 | suggestData: dummySuggestData, 7 | digData: dummyDigData, 8 | }; 9 | -------------------------------------------------------------------------------- /client/src/lib/components/CommandCode.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | {commandText} 8 |
9 | 10 | 16 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import "./app.css"; 2 | import App from "./App.svelte"; 3 | 4 | const app = new App({ 5 | target: document.getElementById("app"), 6 | }); 7 | 8 | export default app; 9 | -------------------------------------------------------------------------------- /client/src/mermaid.ts: -------------------------------------------------------------------------------- 1 | import "./app.css"; 2 | import Mermaid from "./Mermaid.svelte"; 3 | import mermaid from "mermaid"; 4 | 5 | mermaid.mermaidAPI.initialize({ 6 | startOnLoad: false, 7 | sequence: { messageAlign: "right" }, 8 | }); 9 | 10 | const app = new Mermaid({ 11 | target: document.getElementById("app"), 12 | }); 13 | 14 | export default app; 15 | -------------------------------------------------------------------------------- /client/src/models/BaseSuggestData.ts: -------------------------------------------------------------------------------- 1 | import type { IndexTarget } from "./IndexTarget"; 2 | import { ExaminationCommandOption } from "./ExaminationCommandOption"; 3 | import type { ExaminationResult } from "./ExaminationResult"; 4 | import type { 5 | IDbSuggestData, 6 | IExaminationResult, 7 | IIndexTarget, 8 | } from "@/types/gr_param"; 9 | import { plainToInstance } from "class-transformer"; 10 | 11 | export abstract class BaseSuggestData { 12 | query: string; 13 | indexTargets?: IndexTarget[]; 14 | examinationCommandOptions: ExaminationCommandOption[]; 15 | examinationResult?: ExaminationResult; 16 | 17 | protected constructor(suggestData: IDbSuggestData) { 18 | this.query = suggestData.query; 19 | this.indexTargets = this.createIndexTargets(suggestData.indexTargets); 20 | 21 | this.examinationCommandOptions = suggestData.examinationCommandOptions?.map( 22 | (v) => plainToInstance(ExaminationCommandOption, v) 23 | ); 24 | 25 | this.examinationResult = this.createExaminationResult( 26 | suggestData.examinationResult 27 | ); 28 | } 29 | 30 | protected abstract createIndexTargets( 31 | targets?: IIndexTarget[] 32 | ): IndexTarget[]; 33 | 34 | protected abstract createExaminationResult( 35 | result?: IExaminationResult 36 | ): ExaminationResult; 37 | } 38 | -------------------------------------------------------------------------------- /client/src/models/DbLoad.ts: -------------------------------------------------------------------------------- 1 | import { Type } from "class-transformer"; 2 | import { DbLoadOfSql } from "./DbLoadOfSql"; 3 | 4 | export class DbLoad { 5 | name: string; 6 | 7 | @Type(() => DbLoadOfSql) 8 | sqls: DbLoadOfSql[]; 9 | } 10 | -------------------------------------------------------------------------------- /client/src/models/DbLoadOfSql.ts: -------------------------------------------------------------------------------- 1 | export class DbLoadOfSql { 2 | sql: string; 3 | loadMax: number; 4 | loadSum: number; 5 | tokenizedId: string; 6 | } 7 | -------------------------------------------------------------------------------- /client/src/models/DigData.ts: -------------------------------------------------------------------------------- 1 | import { plainToInstance, Type } from "class-transformer"; 2 | import { PerformanceInsightsData } from "@/models/PerformanceInsightsData"; 3 | import { JaegerData } from "@/models/JaegerData"; 4 | import type { IDigData } from "@/types/gr_param"; 5 | 6 | export class DigData { 7 | @Type(() => PerformanceInsightsData) 8 | performanceInsightsData?: PerformanceInsightsData; 9 | 10 | @Type(() => JaegerData) 11 | jaegerData?: JaegerData; 12 | 13 | constructor(iDigData: IDigData) { 14 | if (iDigData.performanceInsights) { 15 | this.performanceInsightsData = plainToInstance( 16 | PerformanceInsightsData, 17 | iDigData.performanceInsights 18 | ); 19 | } 20 | 21 | if (iDigData.jaeger) { 22 | this.jaegerData = plainToInstance(JaegerData, iDigData.jaeger); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/src/models/ExaminationCommandOption.ts: -------------------------------------------------------------------------------- 1 | export class ExaminationCommandOption { 2 | name: string; 3 | value: string; 4 | isShort: boolean; 5 | 6 | ToCommandString(): string { 7 | const dash = this.isShort ? "-" : "--"; 8 | const escapedValue = `${this.value.replace(/(["$`\\])/g, "\\$1")}`; 9 | 10 | if (escapedValue.match(/^\w+$/)) { 11 | return `${dash}${this.name} ${escapedValue}`; 12 | } else { 13 | return `${dash}${this.name} "${escapedValue}"`; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/models/ExaminationIndexResult.ts: -------------------------------------------------------------------------------- 1 | import { IndexTarget } from "./IndexTarget"; 2 | import { Type } from "class-transformer"; 3 | 4 | export class ExaminationIndexResult { 5 | @Type(() => IndexTarget) 6 | indexTarget: IndexTarget; 7 | executionTimeMillis: number; 8 | 9 | toReductionPercent(originalTimeMillis: number): string { 10 | return ( 11 | (1 - this.executionTimeMillis / originalTimeMillis) * 12 | 100 13 | ).toLocaleString(undefined, { 14 | minimumFractionDigits: 2, 15 | maximumFractionDigits: 2, 16 | }); 17 | } 18 | 19 | toIndex(): string { 20 | return this.indexTarget.toString(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/src/models/ExaminationResult.ts: -------------------------------------------------------------------------------- 1 | import { ExaminationIndexResult } from "./ExaminationIndexResult"; 2 | import { Type } from "class-transformer"; 3 | 4 | export class ExaminationResult { 5 | originalTimeMillis: number; 6 | 7 | @Type(() => ExaminationIndexResult) 8 | indexResults: ExaminationIndexResult[]; 9 | } 10 | -------------------------------------------------------------------------------- /client/src/models/HasuraIndexTarget.ts: -------------------------------------------------------------------------------- 1 | import { PostgresIndexTarget } from "@/models/PostgresIndexTarget"; 2 | 3 | export class HasuraIndexTarget extends PostgresIndexTarget { 4 | toExecutableText(): string { 5 | return `curl --request POST \ 6 | --url "\${HASURA_URL}/v2/query" \ 7 | --header 'Content-Type: application/json' \ 8 | --header "x-hasura-admin-secret: \${HASURA_ADMIN_SECRET}" \ 9 | --data '{ 10 | "type": "run_sql", 11 | "args": { 12 | "sql": "${this.toAlterAddSQL()}" 13 | } 14 | }'`; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/models/HasuraSuggestData.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IIndexTarget, 3 | IExaminationResult, 4 | IHasuraSuggestData, 5 | } from "@/types/gr_param"; 6 | import { BaseSuggestData } from "./BaseSuggestData"; 7 | import { plainToInstance } from "class-transformer"; 8 | import type { IndexTarget } from "@/models/IndexTarget"; 9 | import { ExaminationResult } from "@/models/ExaminationResult"; 10 | import { format } from "sql-formatter"; 11 | import { PostgresExplainData } from "@/models/explain_data/PostgresExplainData"; 12 | import { HasuraIndexTarget } from "@/models/HasuraIndexTarget"; 13 | 14 | export class HasuraSuggestData extends BaseSuggestData { 15 | gql: string; 16 | gqlVariables: Record; 17 | analyzeNodes?: PostgresExplainData[]; 18 | summaryText: string; 19 | 20 | constructor(hasuraData: IHasuraSuggestData) { 21 | const suggestData = hasuraData.postgres; 22 | super(suggestData); 23 | 24 | this.query = format(suggestData.query, { 25 | language: "postgresql", 26 | }); 27 | 28 | this.summaryText = suggestData.summaryText; 29 | 30 | this.gql = suggestData.gql; 31 | this.gqlVariables = suggestData.gqlVariables; 32 | 33 | this.analyzeNodes = plainToInstance( 34 | PostgresExplainData, 35 | suggestData.analyzeNodes 36 | ); 37 | } 38 | 39 | protected createIndexTargets(targets?: IIndexTarget[] | null): IndexTarget[] { 40 | return targets?.map((v) => plainToInstance(HasuraIndexTarget, v)); 41 | } 42 | 43 | protected createExaminationResult( 44 | result?: IExaminationResult 45 | ): ExaminationResult { 46 | const res = result ? plainToInstance(ExaminationResult, result) : undefined; 47 | if (!res) return res; 48 | 49 | for (const result of res.indexResults) { 50 | result.indexTarget = plainToInstance( 51 | HasuraIndexTarget, 52 | result.indexTarget 53 | ); 54 | } 55 | 56 | return res; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /client/src/models/IndexColumn.ts: -------------------------------------------------------------------------------- 1 | export class IndexColumn { 2 | name: string; 3 | } 4 | -------------------------------------------------------------------------------- /client/src/models/IndexTarget.ts: -------------------------------------------------------------------------------- 1 | import { IndexColumn } from "./IndexColumn"; 2 | import { Type } from "class-transformer"; 3 | 4 | export class IndexTarget { 5 | tableName: string; 6 | @Type(() => IndexColumn) 7 | columns: IndexColumn[]; 8 | 9 | toString(): string { 10 | const columns = this.columns.map((v) => v.name).join(", "); 11 | return `Table=${this.tableName} / Column=${columns}`; 12 | } 13 | 14 | toGrIndexOption(): string { 15 | return `"${this.tableName}:${this.toGrColumnOption()}"`; 16 | } 17 | 18 | toAlterAddSQL(): string { 19 | const columns = this.columns.map((v) => v.name).join(", "); 20 | 21 | return `ALTER TABLE ${this.tableName} ADD INDEX (${columns});`; 22 | } 23 | 24 | toExecutableText(): string { 25 | return this.toAlterAddSQL(); 26 | } 27 | 28 | private toGrColumnOption(): string { 29 | return this.columns.map((v) => v.name).join("+"); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/models/JaegerData.ts: -------------------------------------------------------------------------------- 1 | import { Type } from "class-transformer"; 2 | import { OtelCompactedTrace } from "@/models/OtelCompactedTrace"; 3 | 4 | export class JaegerData { 5 | uiPath: string; 6 | slowThresholdMilli: number; 7 | sameServiceThreshold: number; 8 | 9 | @Type(() => OtelCompactedTrace) 10 | slowTraces: OtelCompactedTrace[]; 11 | 12 | @Type(() => OtelCompactedTrace) 13 | sameServiceTraces: OtelCompactedTrace[]; 14 | } 15 | -------------------------------------------------------------------------------- /client/src/models/MysqlSuggestData.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IMysqlSuggestData, 3 | IIndexTarget, 4 | IExaminationResult, 5 | } from "@/types/gr_param"; 6 | import { BaseSuggestData } from "./BaseSuggestData"; 7 | import { plainToInstance } from "class-transformer"; 8 | import { IndexTarget } from "@/models/IndexTarget"; 9 | import { ExaminationResult } from "@/models/ExaminationResult"; 10 | import { MysqlAnalyzeData } from "@/models/explain_data/MysqlAnalyzeData"; 11 | 12 | export class MysqlSuggestData extends BaseSuggestData { 13 | analyzeNodes?: MysqlAnalyzeData[]; 14 | 15 | constructor(suggestData: IMysqlSuggestData) { 16 | super(suggestData); 17 | 18 | this.analyzeNodes = plainToInstance( 19 | MysqlAnalyzeData, 20 | suggestData.analyzeNodes 21 | ); 22 | } 23 | 24 | protected createIndexTargets(targets?: IIndexTarget[]): IndexTarget[] { 25 | return targets?.map((v) => plainToInstance(IndexTarget, v)); 26 | } 27 | 28 | protected createExaminationResult( 29 | result?: IExaminationResult 30 | ): ExaminationResult { 31 | return result ? plainToInstance(ExaminationResult, result) : undefined; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client/src/models/OtelCompactedTrace.ts: -------------------------------------------------------------------------------- 1 | import { Type } from "class-transformer"; 2 | import { OtelTraceSpan } from "@/models/OtelTraceSpan"; 3 | import { TraceTree } from "@/models/trace_diagram/TraceTree"; 4 | 5 | export class OtelCompactedTrace { 6 | traceId: string; 7 | sameServiceAccessCount: number; 8 | timeConsumingServiceName: string; 9 | 10 | @Type(() => OtelTraceSpan) 11 | root: OtelTraceSpan; 12 | 13 | toTraceTree(): TraceTree { 14 | const duration = this.root.endTimeMillis - this.root.startTimeMillis; 15 | const root = this.root.toTraceNode(); 16 | return new TraceTree( 17 | this.traceId, 18 | duration, 19 | this.sameServiceAccessCount, 20 | this.timeConsumingServiceName, 21 | root 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/src/models/PerformanceInsightsData.ts: -------------------------------------------------------------------------------- 1 | import { Type } from "class-transformer"; 2 | import { TimeDbLoad } from "@/models/TimeDbLoad"; 3 | 4 | export class PerformanceInsightsData { 5 | @Type(() => TimeDbLoad) 6 | sqlDbLoads: TimeDbLoad[]; 7 | 8 | @Type(() => TimeDbLoad) 9 | tokenizedSqlDbLoads: TimeDbLoad[]; 10 | } 11 | -------------------------------------------------------------------------------- /client/src/models/PostgresIndexTarget.ts: -------------------------------------------------------------------------------- 1 | import { IndexTarget } from "@/models/IndexTarget"; 2 | 3 | export class PostgresIndexTarget extends IndexTarget { 4 | toAlterAddSQL(): string { 5 | const columns = this.columns.map((v) => v.name).join(", "); 6 | 7 | return `CREATE INDEX ON ${this.tableName} (${columns});`; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /client/src/models/PostgresSuggestData.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IIndexTarget, 3 | IPostgresSuggestData, 4 | IExaminationResult, 5 | } from "@/types/gr_param"; 6 | import { BaseSuggestData } from "./BaseSuggestData"; 7 | import { plainToInstance } from "class-transformer"; 8 | import type { IndexTarget } from "@/models/IndexTarget"; 9 | import { PostgresIndexTarget } from "@/models/PostgresIndexTarget"; 10 | import { ExaminationResult } from "@/models/ExaminationResult"; 11 | import { PostgresAnalyzeData } from "@/models/explain_data/PostgresAnalyzeData"; 12 | 13 | export class PostgresSuggestData extends BaseSuggestData { 14 | analyzeNodes?: PostgresAnalyzeData[]; 15 | summaryText: string; 16 | 17 | constructor(suggestData: IPostgresSuggestData) { 18 | super(suggestData); 19 | 20 | this.analyzeNodes = plainToInstance( 21 | PostgresAnalyzeData, 22 | suggestData.analyzeNodes 23 | ); 24 | this.summaryText = suggestData.summaryText; 25 | } 26 | 27 | protected createIndexTargets(targets?: IIndexTarget[] | null): IndexTarget[] { 28 | return targets?.map((v) => plainToInstance(PostgresIndexTarget, v)); 29 | } 30 | 31 | protected createExaminationResult( 32 | result?: IExaminationResult 33 | ): ExaminationResult { 34 | const res = result ? plainToInstance(ExaminationResult, result) : undefined; 35 | if (!res) return res; 36 | 37 | for (const result of res.indexResults) { 38 | result.indexTarget = plainToInstance( 39 | PostgresIndexTarget, 40 | result.indexTarget 41 | ); 42 | } 43 | 44 | return res; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/src/models/SuggestData.ts: -------------------------------------------------------------------------------- 1 | import type { ISuggestData } from "@/types/gr_param"; 2 | import { MysqlSuggestData } from "./MysqlSuggestData"; 3 | import { PostgresSuggestData } from "./PostgresSuggestData"; 4 | import { HasuraSuggestData } from "@/models/HasuraSuggestData"; 5 | 6 | export class SuggestData { 7 | mysqlSuggestData?: MysqlSuggestData; 8 | postgresSuggestData?: PostgresSuggestData; 9 | hasuraSuggestData?: HasuraSuggestData; 10 | 11 | constructor(iSuggestData: ISuggestData) { 12 | if (iSuggestData.mysql) { 13 | this.mysqlSuggestData = new MysqlSuggestData(iSuggestData.mysql); 14 | } 15 | 16 | if (iSuggestData.postgres) { 17 | this.postgresSuggestData = new PostgresSuggestData(iSuggestData.postgres); 18 | } 19 | 20 | if (iSuggestData.hasura) { 21 | this.hasuraSuggestData = new HasuraSuggestData(iSuggestData.hasura); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/src/models/TimeDbLoad.ts: -------------------------------------------------------------------------------- 1 | import { Type } from "class-transformer"; 2 | import { DbLoad } from "./DbLoad"; 3 | 4 | export class TimeDbLoad { 5 | timestamp: number; 6 | 7 | @Type(() => DbLoad) 8 | databases: DbLoad[]; 9 | 10 | get date(): Date { 11 | return new Date(this.timestamp); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/src/models/explain_data/DbAnalyzeData.ts: -------------------------------------------------------------------------------- 1 | import { DbExplainData } from "@/models/explain_data/DbExplainData"; 2 | 3 | export abstract class DbAnalyzeData extends DbExplainData { 4 | actualTimeFirstRow?: number; 5 | actualTimeAvg?: number; 6 | actualReturnedRows?: number; 7 | actualLoopCount?: number; 8 | } 9 | -------------------------------------------------------------------------------- /client/src/models/explain_data/DbExplainData.ts: -------------------------------------------------------------------------------- 1 | export abstract class DbExplainData { 2 | text: string; 3 | title: string; 4 | tableName?: string; 5 | 6 | abstract children?: this[]; 7 | 8 | abstract calculateEndTime(startTime: number): number; 9 | } 10 | -------------------------------------------------------------------------------- /client/src/models/explain_data/MysqlAnalyzeData.ts: -------------------------------------------------------------------------------- 1 | import { DbAnalyzeData } from "@/models/explain_data/DbAnalyzeData"; 2 | import { Type } from "class-transformer"; 3 | import "reflect-metadata"; 4 | 5 | export class MysqlAnalyzeData extends DbAnalyzeData { 6 | @Type(() => MysqlAnalyzeData) 7 | children?: this[]; 8 | 9 | estimatedInitCost?: number; 10 | estimatedCost?: number; 11 | estimatedReturnedRows?: number; 12 | 13 | calculateEndTime(startTime: number): number { 14 | if (this.actualLoopCount > 1) { 15 | if (this.actualLoopCount > 1000 && this.actualTimeAvg === 0) { 16 | // ActualTimeAvg doesn't show the value less than 0.000, however, 17 | // when the number of loop is much bigger, time can be meaningful even the result of multiplication is zero. 18 | // To handle the problem, assume ActualTimeAvg is some less than 0.000 19 | return startTime + 0.0001 * this.actualLoopCount; 20 | } else { 21 | return startTime + this.actualTimeAvg * this.actualLoopCount; 22 | } 23 | } else { 24 | return this.actualTimeAvg; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/src/models/explain_data/PostgresAnalyzeData.ts: -------------------------------------------------------------------------------- 1 | import { Type } from "class-transformer"; 2 | import { DbAnalyzeData } from "@/models/explain_data/DbAnalyzeData"; 3 | 4 | export class PostgresAnalyzeData extends DbAnalyzeData { 5 | @Type(() => PostgresAnalyzeData) 6 | children?: this[]; 7 | 8 | estimatedInitCost: number; 9 | estimatedCost: number; 10 | estimatedReturnedRows: number; 11 | estimatedWidth: number; 12 | 13 | calculateEndTime(_startTime: number): number { 14 | // actualTimeAvg seems not the average time of loops for PostgreSQL even doc says so 15 | return this.actualTimeAvg; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/models/explain_data/PostgresExplainData.ts: -------------------------------------------------------------------------------- 1 | import { DbExplainData } from "@/models/explain_data/DbExplainData"; 2 | import { Type } from "class-transformer"; 3 | 4 | export class PostgresExplainData extends DbExplainData { 5 | @Type(() => PostgresExplainData) 6 | children?: this[]; 7 | 8 | estimatedInitCost: number; 9 | estimatedCost: number; 10 | estimatedReturnedRows: number; 11 | estimatedWidth: number; 12 | 13 | calculateEndTime(_startTime: number): number { 14 | return this.estimatedCost; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/models/trace_diagram/ITraceNode.ts: -------------------------------------------------------------------------------- 1 | export abstract class ITraceNode { 2 | protected constructor( 3 | public serviceName: string, 4 | public children: ITraceNode[] 5 | ) {} 6 | 7 | abstract toMermaidStartArrow(fromServiceName: string): string; 8 | abstract toMermaidEndArrow(fromServiceName: string): string; 9 | 10 | hasChild(): boolean { 11 | return this.children.length > 0; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/src/models/trace_diagram/TraceNode.ts: -------------------------------------------------------------------------------- 1 | import { ITraceNode } from "@/models/trace_diagram/ITraceNode"; 2 | 3 | export class TraceNode extends ITraceNode { 4 | constructor(serviceName: string, children: ITraceNode[] = []) { 5 | super(serviceName, children); 6 | } 7 | 8 | toMermaidStartArrow(fromServiceName: string): string { 9 | return `${fromServiceName}->>${this.serviceName}: ${this.serviceName}`; 10 | } 11 | 12 | toMermaidEndArrow(fromServiceName: string): string { 13 | return `${this.serviceName}->>${fromServiceName}: ${fromServiceName}`; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/src/models/trace_diagram/TraceRepeatNode.ts: -------------------------------------------------------------------------------- 1 | import { ITraceNode } from "@/models/trace_diagram/ITraceNode"; 2 | 3 | const SERVICE_NAME = "repeat"; 4 | 5 | export class TraceRepeatNode extends ITraceNode { 6 | constructor( 7 | public repeat: number, 8 | public toService: string, 9 | children: ITraceNode[] = [] 10 | ) { 11 | super(SERVICE_NAME, children); 12 | } 13 | 14 | toMermaidStartArrow(fromServiceName: string): string { 15 | let texts = []; 16 | const repeatCount = Math.min(this.repeat, 3); 17 | for (let j = 0; j < repeatCount; j++) { 18 | if (j === 0) { 19 | const text = `${fromServiceName}->>${this.toService}: More ${this.repeat} times...`; 20 | texts.push(text); 21 | } else { 22 | texts.push(`${fromServiceName}->>${this.toService}: `); 23 | } 24 | } 25 | 26 | return texts.join("\n"); 27 | } 28 | 29 | toMermaidEndArrow(_: string): string { 30 | return ""; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/src/models/trace_diagram/TraceTree.ts: -------------------------------------------------------------------------------- 1 | import type { ITraceNode } from "@/models/trace_diagram/ITraceNode"; 2 | 3 | export class TraceTree { 4 | constructor( 5 | public traceId: string, 6 | public durationMilli: number, 7 | public sameServiceAccessCount: number, 8 | public timeConsumingServiceName: string, 9 | public root: ITraceNode 10 | ) {} 11 | 12 | toMermaidDiagram(): string { 13 | if (this.root.children.length === 0) { 14 | return this.toRootOnlyMermaidDiagram(); 15 | } 16 | 17 | return "sequenceDiagram\r\n" + this.toMermaidDiagramRecursive(this.root); 18 | } 19 | 20 | private toRootOnlyMermaidDiagram(): string { 21 | return ` 22 | sequenceDiagram 23 | ${this.root.serviceName}->>${this.root.serviceName}: ${" "} 24 | `; 25 | } 26 | 27 | private toMermaidDiagramRecursive(node: ITraceNode): string { 28 | let text = ""; 29 | const serviceName = node.serviceName; 30 | 31 | for (let i = 0; i < node.children.length; i++) { 32 | const childTrace = node.children[i]; 33 | text += childTrace.toMermaidStartArrow(serviceName) + "\n"; 34 | if (childTrace.hasChild()) { 35 | text += `activate ${childTrace.serviceName}\n`; 36 | } 37 | 38 | text += this.toMermaidDiagramRecursive(childTrace); 39 | 40 | if (childTrace.hasChild()) { 41 | // Not show return arrow when the child trace is at the tail to make diagram smaller. 42 | text += childTrace.toMermaidEndArrow(serviceName) + "\n"; 43 | 44 | text += `deactivate ${childTrace.serviceName}\n`; 45 | } 46 | } 47 | return text; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /client/src/pages/dig/jaeger/DigJaegerPage.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 26 | 27 | 28 | 29 | 38 | 39 | -------------------------------------------------------------------------------- /client/src/pages/dig/jaeger/TraceDiagram.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 |
23 |

TraceId: {trace.traceId}

24 |

Open detail

25 |
26 |
27 | 28 | 30 | -------------------------------------------------------------------------------- /client/src/pages/dig/performance_insights/DigPerformanceInsightsPage.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 | 15 | Plain 16 | Tokenized 17 | 18 |
19 | 20 | {#if activeTab === "plain"} 21 |
22 |
23 | Heaviest SQLs 24 | 25 |
26 |
27 | 28 |
29 |
30 | Heavy SQL Timeline 31 | 32 |
33 |
34 | {:else} 35 |
36 |
37 | Heaviest Tokenized SQLs 38 | 42 |
43 |
44 | 45 |
46 |
47 | Heavy Tokenized SQL Timeline 48 | 52 |
53 |
54 | {/if} 55 | 56 | 65 | -------------------------------------------------------------------------------- /client/src/pages/dig/performance_insights/util/CommandText.ts: -------------------------------------------------------------------------------- 1 | export const buildCommandText = (dbName: string, sql: string): string => { 2 | const db = dbName ?? "..."; 3 | const query = sql ?? "..."; 4 | 5 | return `DB_DATABASE="${db}" gr db suggest -o "suggest.html" \\\n\t-q "${query}"`; 6 | }; 7 | -------------------------------------------------------------------------------- /client/src/pages/dig/performance_insights/util/Databases.ts: -------------------------------------------------------------------------------- 1 | import type { TimeDbLoad } from "@/models/TimeDbLoad"; 2 | 3 | export const getAllDatabase = (dbLoads?: TimeDbLoad[]): string[] => { 4 | if (!dbLoads) return []; 5 | 6 | const dbs = dbLoads.map((c) => c.databases.map((d) => d.name)).flat(); 7 | return Array.from(new Set(dbs)).sort(); 8 | }; 9 | 10 | export const getFirstDatabaseAsArray = (databases: string[]): string[] => { 11 | if (databases.length === 0) { 12 | return []; 13 | } 14 | 15 | return [databases[0]]; 16 | }; 17 | -------------------------------------------------------------------------------- /client/src/pages/dig/performance_insights/util/LoadTooltip.ts: -------------------------------------------------------------------------------- 1 | type LoadProp = { 2 | dbName: string; 3 | sql: string; 4 | loadMaxPercent: number; 5 | date: Date; 6 | }; 7 | 8 | export const createLoadMaxTooltip = ( 9 | { date, dbName, loadMaxPercent, sql }: LoadProp, 10 | maxHeight: number 11 | ): string => { 12 | return ` 13 |
14 | ${date.toUTCString()}
15 | Database: ${dbName}
16 |
17 | db.load.avg:  18 | 19 | ${loadMaxPercent.toFixed(2)}% 20 | 21 |
22 | ${sql} 23 |
24 | `; 25 | }; 26 | 27 | type LoadSumProp = { 28 | dbName: string; 29 | sql: string; 30 | loadSumPercent: number; 31 | }; 32 | 33 | export const createLoadSumTooltip = ( 34 | { dbName, loadSumPercent, sql }: LoadSumProp, 35 | maxHeight: number 36 | ): string => { 37 | return ` 38 |
39 | Database: ${dbName}
40 |
41 | Sum of db.load.avg:  42 | 43 | ${loadSumPercent.toFixed(2)}% 44 | 45 |
46 | ${sql} 47 |
48 | `; 49 | }; 50 | -------------------------------------------------------------------------------- /client/src/pages/dig/performance_insights/util/chart/LoadPropChartSereies.ts: -------------------------------------------------------------------------------- 1 | import type { GrChartSeries, GrChartSeriesData } from "@/lib/GrChart"; 2 | import type { TimeDbLoad } from "@/models/TimeDbLoad"; 3 | import type { DbLoadOfSql } from "@/models/DbLoadOfSql"; 4 | import type { DbLoad } from "@/models/DbLoad"; 5 | 6 | type SeriesRecord = Record[number]>>; 7 | 8 | export const createLoadPropChartSeries = ( 9 | dbLoads: TimeDbLoad[], 10 | targetDatabases: string[], 11 | threshold: number, 12 | buildDataFn: ( 13 | timeDbLoad: TimeDbLoad, 14 | dbLoad: DbLoad, 15 | loadOfSql: DbLoadOfSql 16 | ) => GrChartSeriesData 17 | ): GrChartSeries => { 18 | let series: GrChartSeries = []; 19 | let knownSeries: SeriesRecord = {}; 20 | 21 | for (let i = 0; i < dbLoads.length; i++) { 22 | const timeDbLoad = dbLoads[i]; 23 | for (const dbLoad of timeDbLoad.databases) { 24 | if (!targetDatabases.includes(dbLoad.name)) continue; 25 | 26 | for (const loadOfSql of dbLoad.sqls) { 27 | if (loadOfSql.loadMax < threshold) continue; 28 | 29 | if (!knownSeries[dbLoad.name]) { 30 | knownSeries[dbLoad.name] = {}; 31 | } 32 | const dbSeries = knownSeries[dbLoad.name]; 33 | if (!dbSeries[loadOfSql.sql]) { 34 | const seriesData = { 35 | database: dbLoad.name, 36 | name: loadOfSql.sql, 37 | data: Array(i).fill({ 38 | x: timeDbLoad.date, 39 | y: 0, 40 | }), 41 | }; 42 | dbSeries[loadOfSql.sql] = seriesData; 43 | series.push(seriesData); 44 | } 45 | 46 | dbSeries[loadOfSql.sql].data.push( 47 | buildDataFn(timeDbLoad, dbLoad, loadOfSql) 48 | ); 49 | } 50 | } 51 | 52 | for (const s of series) { 53 | if (s.data.length < i + 1) { 54 | s.data.push({ 55 | x: timeDbLoad.date, 56 | y: 0, 57 | }); 58 | } 59 | } 60 | } 61 | 62 | return series; 63 | }; 64 | -------------------------------------------------------------------------------- /client/src/pages/dig/performance_insights/util/chart/TokenizedLoad.ts: -------------------------------------------------------------------------------- 1 | import type { TimeDbLoad } from "@/models/TimeDbLoad"; 2 | import type { DbLoadOfSql } from "@/models/DbLoadOfSql"; 3 | 4 | export type RawLoadOfSql = { 5 | dbName: string; 6 | load: DbLoadOfSql; 7 | date: Date; 8 | }; 9 | 10 | export const createRawLoads = ( 11 | tokenizedLoads: TimeDbLoad[] 12 | ): Record => { 13 | const rawLoads = {}; 14 | 15 | for (const load of tokenizedLoads) { 16 | for (const dbLoad of load.databases) { 17 | for (const loadOfSql of dbLoad.sqls) { 18 | if (!rawLoads[loadOfSql.tokenizedId]) { 19 | rawLoads[loadOfSql.tokenizedId] = []; 20 | } 21 | rawLoads[loadOfSql.tokenizedId].push({ 22 | dbName: dbLoad.name, 23 | load: loadOfSql, 24 | date: load.date, 25 | }); 26 | } 27 | } 28 | } 29 | 30 | return rawLoads; 31 | }; 32 | 33 | export const pickRawLoads = ( 34 | rawLoads: Record, 35 | size: number, 36 | tokenizedId: string 37 | ): RawLoadOfSql[] => { 38 | const loads = rawLoads[tokenizedId]; 39 | const sortedLoads = [...loads].sort( 40 | (l1, l2) => l2.load.loadMax - l1.load.loadMax 41 | ); 42 | 43 | return sortedLoads.slice(0, size); 44 | }; 45 | -------------------------------------------------------------------------------- /client/src/pages/suggest/hasura/ExaminationLineMenu.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 17 | 18 | 19 | 20 | 46 | -------------------------------------------------------------------------------- /client/src/pages/suggest/mysql/SuggestMysqlPage.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 | {#if suggestData.query} 17 | 18 | 19 | </DetailsCard> 20 | {/if} 21 | 22 | {#if suggestData.analyzeNodes} 23 | <DetailsCard title="Explain Tree" open testId="explain"> 24 | <ExplainText 25 | {highlightIndexKey} 26 | analyzeNodes={suggestData.analyzeNodes} 27 | /> 28 | </DetailsCard> 29 | {/if} 30 | 31 | {#if suggestData.analyzeNodes} 32 | <DetailsCard title="Execution Timeline" open testId="explainChart"> 33 | <ExplainAnalyzeChart 34 | {highlightIndexKey} 35 | chartDescription="Execution time based timeline from EXPLAIN ANALYZE" 36 | analyzeNodes={suggestData.analyzeNodes} 37 | /> 38 | </DetailsCard> 39 | {/if} 40 | 41 | {#if suggestData.indexTargets} 42 | <DetailsCard 43 | title="Index suggestion" 44 | open={!suggestData.examinationResult} 45 | testId="suggest" 46 | > 47 | <IndexSuggestion 48 | subCommandKey="mysql" 49 | examinationCommandOptions={suggestData.examinationCommandOptions} 50 | indexTargets={suggestData.indexTargets} 51 | /> 52 | </DetailsCard> 53 | {/if} 54 | 55 | {#if suggestData.examinationResult} 56 | <DetailsCard title="Examination Result" open testId="examination"> 57 | <ExaminationResultTable result={suggestData.examinationResult} /> 58 | </DetailsCard> 59 | {/if} 60 | </main> 61 | 62 | <style> 63 | </style> 64 | -------------------------------------------------------------------------------- /client/src/stores/gr_param.ts: -------------------------------------------------------------------------------- 1 | import { readable } from "svelte/store"; 2 | import { SuggestData } from "@/models/SuggestData"; 3 | import { DigData } from "@/models/DigData"; 4 | 5 | export const grParam = readable({ 6 | dev: window.grParam.dev || false, 7 | suggestData: window.grParam.suggestData 8 | ? new SuggestData(window.grParam.suggestData) 9 | : null, 10 | 11 | digData: window.grParam.digData ? new DigData(window.grParam.digData) : null, 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/types/apexcharts.d.ts: -------------------------------------------------------------------------------- 1 | // Make possible to work autocomplete for the instance of ApexChart(). 2 | import "apexcharts"; 3 | 4 | type ChartW<T> = { config: { series: T } }; 5 | type ChartContext<T> = { w: ChartW<T> }; 6 | type ChartPoint<T> = { 7 | dataPointIndex: number; 8 | seriesIndex: number; 9 | w: ChartW<T>; 10 | }; 11 | type ChartConfig = { seriesIndex: number; dataPointIndex: number }; 12 | -------------------------------------------------------------------------------- /client/src/types/svelte-chota.d.ts: -------------------------------------------------------------------------------- 1 | declare module "svelte-chota" { 2 | import { SvelteComponentTyped } from "svelte"; 3 | export class Tab extends SvelteComponentTyped {} 4 | export class Tabs extends SvelteComponentTyped {} 5 | export class Details extends SvelteComponentTyped {} 6 | export class Radio extends SvelteComponentTyped {} 7 | export class Checkbox extends SvelteComponentTyped {} 8 | export class Nav extends SvelteComponentTyped {} 9 | export class Card extends SvelteComponentTyped {} 10 | export class Button extends SvelteComponentTyped {} 11 | export class Modal extends SvelteComponentTyped {} 12 | } 13 | -------------------------------------------------------------------------------- /client/src/types/window.d.ts: -------------------------------------------------------------------------------- 1 | import type { IGrParam } from "./gr_param"; 2 | 3 | declare global { 4 | interface Window { 5 | grParam: IGrParam; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="svelte" /> 2 | /// <reference types="vite/client" /> 3 | -------------------------------------------------------------------------------- /client/svelte.config.js: -------------------------------------------------------------------------------- 1 | import sveltePreprocess from "svelte-preprocess"; 2 | 3 | export default { 4 | // Consult https://github.com/sveltejs/svelte-preprocess 5 | // for more information about preprocessors 6 | preprocess: sveltePreprocess(), 7 | }; 8 | -------------------------------------------------------------------------------- /client/test/src/pages/dig/jaeger/DigJaegerPage.test.ts: -------------------------------------------------------------------------------- 1 | import { plainToInstance } from "class-transformer"; 2 | import { JaegerData } from "@/models/JaegerData"; 3 | import { render, screen } from "@testing-library/svelte"; 4 | import DigJaegerPage from "@/pages/dig/jaeger/DigJaegerPage.svelte"; 5 | 6 | describe("DigJaegerPage", () => { 7 | describe("smoke test", () => { 8 | const jaegerData = plainToInstance(JaegerData, { 9 | uiPath: "http://localhost:16686", 10 | slowThresholdMilli: 100, 11 | sameServiceThreshold: 5, 12 | slowTraces: [], 13 | sameServiceTraces: [], 14 | }); 15 | 16 | it("renders slowTraces", () => { 17 | render(DigJaegerPage, { 18 | jaegerData: jaegerData, 19 | }); 20 | const data = screen.getByTestId("slowTraces"); 21 | expect(data).toBeInTheDocument(); 22 | }); 23 | 24 | it("renders sameServiceTraces", () => { 25 | render(DigJaegerPage, { 26 | jaegerData: jaegerData, 27 | }); 28 | const data = screen.getByTestId("sameServiceTraces"); 29 | expect(data).toBeInTheDocument(); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /client/test/src/pages/dig/performance_insights/DigPerformanceInsightsPage.test.ts: -------------------------------------------------------------------------------- 1 | import DigPerformanceInsightsPage from "@/pages/dig/performance_insights/DigPerformanceInsightsPage.svelte"; 2 | import { render, screen } from "@testing-library/svelte"; 3 | import { plainToInstance } from "class-transformer"; 4 | import { PerformanceInsightsData } from "@/models/PerformanceInsightsData"; 5 | import ApexCharts from "apexcharts"; 6 | 7 | describe("DigPerformanceInsightsPage", () => { 8 | beforeEach(() => { 9 | jest.spyOn(ApexCharts.prototype, "render").mockReturnValue(null); 10 | }); 11 | 12 | describe("smoke test", () => { 13 | const performanceInsightsData = plainToInstance(PerformanceInsightsData, { 14 | sqlDbLoads: [], 15 | tokenizedSqlDbLoads: [], 16 | }); 17 | 18 | it("renders sqls", () => { 19 | render(DigPerformanceInsightsPage, { 20 | performanceInsightsData: performanceInsightsData, 21 | }); 22 | const data = screen.getByTestId("sqls"); 23 | expect(data).toBeInTheDocument(); 24 | }); 25 | 26 | it("renders timeline", () => { 27 | render(DigPerformanceInsightsPage, { 28 | performanceInsightsData: performanceInsightsData, 29 | }); 30 | const data = screen.getByTestId("timeline"); 31 | expect(data).toBeInTheDocument(); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /client/test/src/pages/suggest/mysql/SuggestMysqlPage.test.ts: -------------------------------------------------------------------------------- 1 | import SuggestMysqlPage from "@/pages/suggest/mysql/SuggestMysqlPage.svelte"; 2 | import { render, screen } from "@testing-library/svelte"; 3 | import { MysqlSuggestData } from "@/models/MysqlSuggestData"; 4 | import ApexCharts from "apexcharts"; 5 | 6 | describe("SuggestMysqlPage", () => { 7 | beforeEach(() => { 8 | jest.spyOn(ApexCharts.prototype, "render").mockReturnValue(null); 9 | jest.spyOn(ApexCharts.prototype, "destroy").mockReturnValue(null); 10 | }); 11 | 12 | describe("smoke test", () => { 13 | const suggestData = new MysqlSuggestData({ 14 | query: "SELECT * FROM users", 15 | indexTargets: [], 16 | examinationCommandOptions: [], 17 | examinationResult: { 18 | originalTimeMillis: 11, 19 | indexResults: [], 20 | }, 21 | analyzeNodes: [], 22 | }); 23 | 24 | it("renders sql", () => { 25 | render(SuggestMysqlPage, { 26 | suggestData: suggestData, 27 | }); 28 | const data = screen.getByTestId("sql"); 29 | expect(data).toBeInTheDocument(); 30 | }); 31 | 32 | it("renders explain", () => { 33 | render(SuggestMysqlPage, { 34 | suggestData: suggestData, 35 | }); 36 | const data = screen.getByTestId("explain"); 37 | expect(data).toBeInTheDocument(); 38 | }); 39 | 40 | it("renders explainChart", () => { 41 | render(SuggestMysqlPage, { 42 | suggestData: suggestData, 43 | }); 44 | const data = screen.getByTestId("explainChart"); 45 | expect(data).toBeInTheDocument(); 46 | }); 47 | 48 | it("renders suggest", () => { 49 | render(SuggestMysqlPage, { 50 | suggestData: suggestData, 51 | }); 52 | const data = screen.getByTestId("suggest"); 53 | expect(data).toBeInTheDocument(); 54 | }); 55 | 56 | it("renders examination", () => { 57 | render(SuggestMysqlPage, { 58 | suggestData: suggestData, 59 | }); 60 | const data = screen.getByTestId("examination"); 61 | expect(data).toBeInTheDocument(); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /client/test/src/pages/suggest/postgres/SuggestPostgresPage.test.ts: -------------------------------------------------------------------------------- 1 | import SuggestPostgresPage from "@/pages/suggest/postgres/SuggestPostgresPage.svelte"; 2 | import { render, screen } from "@testing-library/svelte"; 3 | import { PostgresSuggestData } from "@/models/PostgresSuggestData"; 4 | import ApexCharts from "apexcharts"; 5 | 6 | describe("SuggestPostgresPage", () => { 7 | beforeEach(() => { 8 | jest.spyOn(ApexCharts.prototype, "render").mockReturnValue(null); 9 | jest.spyOn(ApexCharts.prototype, "destroy").mockReturnValue(null); 10 | }); 11 | 12 | describe("smoke test", () => { 13 | const suggestData = new PostgresSuggestData({ 14 | query: "SELECT * FROM users", 15 | indexTargets: [], 16 | examinationCommandOptions: [], 17 | examinationResult: { 18 | originalTimeMillis: 11, 19 | indexResults: [], 20 | }, 21 | analyzeNodes: [], 22 | summaryText: "", 23 | }); 24 | 25 | it("renders sql", () => { 26 | render(SuggestPostgresPage, { 27 | suggestData: suggestData, 28 | }); 29 | const data = screen.getByTestId("sql"); 30 | expect(data).toBeInTheDocument(); 31 | }); 32 | 33 | it("renders explain", () => { 34 | render(SuggestPostgresPage, { 35 | suggestData: suggestData, 36 | }); 37 | const data = screen.getByTestId("explain"); 38 | expect(data).toBeInTheDocument(); 39 | }); 40 | 41 | it("renders explainChart", () => { 42 | render(SuggestPostgresPage, { 43 | suggestData: suggestData, 44 | }); 45 | const data = screen.getByTestId("explainChart"); 46 | expect(data).toBeInTheDocument(); 47 | }); 48 | 49 | it("renders suggest", () => { 50 | render(SuggestPostgresPage, { 51 | suggestData: suggestData, 52 | }); 53 | const data = screen.getByTestId("suggest"); 54 | expect(data).toBeInTheDocument(); 55 | }); 56 | 57 | it("renders examination", () => { 58 | render(SuggestPostgresPage, { 59 | suggestData: suggestData, 60 | }); 61 | const data = screen.getByTestId("examination"); 62 | expect(data).toBeInTheDocument(); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "experimentalDecorators": true, 5 | "target": "ESNext", 6 | "useDefineForClassFields": true, 7 | "module": "ESNext", 8 | "resolveJsonModule": true, 9 | "baseUrl": ".", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | }, 13 | /** 14 | * Typecheck JS in `.svelte` and `.js` files by default. 15 | * Disable checkJs if you'd like to use dynamic types in JS. 16 | * Note that setting allowJs false does not prevent the use 17 | * of JS in `.svelte` files. 18 | */ 19 | "allowJs": true, 20 | "checkJs": true, 21 | "isolatedModules": true 22 | }, 23 | "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /client/vite-common.config.ts: -------------------------------------------------------------------------------- 1 | import { svelte } from "@sveltejs/vite-plugin-svelte"; 2 | 3 | // https://vitejs.dev/config/ 4 | export const config = { 5 | plugins: [svelte()], 6 | build: { 7 | emptyOutDir: false, 8 | rollupOptions: { 9 | input: ["src/dummy.ts"], 10 | output: { 11 | assetFileNames: "assets/[name].[ext]", 12 | entryFileNames: "assets/[name].js", 13 | }, 14 | }, 15 | }, 16 | resolve: { 17 | alias: [{ find: "@", replacement: "/src" }], 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /client/vite-main.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { config } from "./vite-common.config"; 3 | 4 | config["build"]["rollupOptions"]["input"] = ["src/main.ts"]; 5 | 6 | export default defineConfig(config); 7 | -------------------------------------------------------------------------------- /client/vite-mermaid.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { config } from "./vite-common.config"; 3 | 4 | config["build"]["rollupOptions"]["input"] = ["src/mermaid.ts"]; 5 | 6 | export default defineConfig(config); 7 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { config } from "./vite-common.config"; 3 | 4 | config["build"]["rollupOptions"]["input"] = [ 5 | "src/main.ts", 6 | "src/mermaid.ts", 7 | "src/dummy.ts", 8 | ]; 9 | 10 | export default defineConfig(config); 11 | -------------------------------------------------------------------------------- /cmd/app.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/cmd/app" 5 | "github.com/mrasu/GravityR/cmd/flag" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // appCmd represents the db command 10 | var appCmd = &cobra.Command{ 11 | Use: "app", 12 | Short: "Inspect entire application", 13 | } 14 | 15 | func init() { 16 | appCmd.AddCommand(app.DigCmd) 17 | 18 | flg := appCmd.PersistentFlags() 19 | flg.StringVarP(&flag.AppFlag.Output, "output", "o", "", "[Required] File name to output result html") 20 | err := cobra.MarkFlagRequired(flg, "output") 21 | if err != nil { 22 | panic(err) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cmd/app/dig.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/cmd/app/dig" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var DigCmd = &cobra.Command{ 9 | Use: "dig", 10 | Short: "Dig application behavior", 11 | } 12 | 13 | func init() { 14 | DigCmd.AddCommand(dig.JaegerCmd) 15 | } 16 | -------------------------------------------------------------------------------- /cmd/db.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/cmd/db" 5 | "github.com/mrasu/GravityR/cmd/flag" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // dbCmd represents the db command 10 | var dbCmd = &cobra.Command{ 11 | Use: "db", 12 | Short: "Inspect DB-ish tool", 13 | } 14 | 15 | func init() { 16 | dbCmd.AddCommand(db.SuggestCmd) 17 | dbCmd.AddCommand(db.DigCmd) 18 | 19 | flg := dbCmd.PersistentFlags() 20 | flg.StringVarP(&flag.DbFlag.Output, "output", "o", "", "[Required] File name to output result html") 21 | err := cobra.MarkFlagRequired(flg, "output") 22 | if err != nil { 23 | panic(err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /cmd/db/dig.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/cmd/db/dig" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var DigCmd = &cobra.Command{ 9 | Use: "dig", 10 | Short: "Dig database behavior", 11 | } 12 | 13 | func init() { 14 | DigCmd.AddCommand(dig.PerformanceInsightsCmd) 15 | 16 | // Here you will define your flags and configuration settings. 17 | 18 | // Cobra supports Persistent Flags which will work for this command 19 | // and all subcommands, e.g.: 20 | // SuggestCmd.PersistentFlags().String("foo", "", "A help for foo") 21 | 22 | // Cobra supports local flags which will only run when this command 23 | // flg := DigCmd.Flags() 24 | // flg.BoolVar(&runsExamination, "with-examine", false, "Examine query by adding index") 25 | } 26 | -------------------------------------------------------------------------------- /cmd/db/suggest.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/cmd/db/suggest" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var SuggestCmd = &cobra.Command{ 9 | Use: "suggest", 10 | Short: "Inspect and suggest", 11 | } 12 | 13 | func init() { 14 | SuggestCmd.AddCommand(suggest.MySqlCmd) 15 | SuggestCmd.AddCommand(suggest.PostgresCmd) 16 | SuggestCmd.AddCommand(suggest.HasuraCmd) 17 | } 18 | -------------------------------------------------------------------------------- /cmd/db/suggest/hasura.go: -------------------------------------------------------------------------------- 1 | package suggest 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/cmd/db/suggest/hasura" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var HasuraCmd = &cobra.Command{ 9 | Use: "hasura", 10 | Short: "Suggest ways to increase Hasura's performance", 11 | } 12 | 13 | func init() { 14 | HasuraCmd.AddCommand(hasura.PostgresCmd) 15 | } 16 | -------------------------------------------------------------------------------- /cmd/flag/app_flag.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | var AppFlag = &appFlag{} 4 | 5 | type appFlag struct { 6 | Output string 7 | } 8 | -------------------------------------------------------------------------------- /cmd/flag/db_flag.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | var DbFlag = &dbFlag{} 4 | 5 | type dbFlag struct { 6 | Output string 7 | } 8 | -------------------------------------------------------------------------------- /cmd/flag/global_flag.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | var GlobalFlag = &globalFlag{} 4 | 5 | type globalFlag struct { 6 | Verbose bool 7 | UseMock bool 8 | } 9 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/cmd/flag" 5 | "github.com/mrasu/GravityR/cmd/util" 6 | "github.com/rs/zerolog" 7 | "github.com/rs/zerolog/log" 8 | "os" 9 | 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // rootCmd represents the base command when called without any subcommands 14 | var rootCmd = &cobra.Command{ 15 | Use: "gr", 16 | SilenceUsage: true, 17 | SilenceErrors: true, 18 | Short: "Gravity Radar to remove bottleneck in your application", 19 | Long: `GravityR is Gravity-Radar. 20 | This exists to reduce time to find bottleneck in your application. 21 | And also this is to solve the problems faster and easier. 22 | `, 23 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 24 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) 25 | if flag.GlobalFlag.Verbose { 26 | zerolog.SetGlobalLevel(zerolog.DebugLevel) 27 | } else { 28 | zerolog.SetGlobalLevel(zerolog.InfoLevel) 29 | } 30 | 31 | if flag.GlobalFlag.UseMock { 32 | log.Info().Msg("NOTE: Using mock") 33 | } 34 | }, 35 | } 36 | 37 | // Execute adds all child commands to the root command and sets flags appropriately. 38 | // This is called by main.main(). It only needs to happen once to the rootCmd. 39 | func Execute() { 40 | err := rootCmd.Execute() 41 | if err != nil { 42 | util.LogError(err) 43 | os.Exit(1) 44 | } 45 | } 46 | 47 | func init() { 48 | rootCmd.AddCommand(dbCmd) 49 | rootCmd.AddCommand(appCmd) 50 | rootCmd.AddCommand(versionCmd) 51 | 52 | rootCmd.PersistentFlags().BoolVar(&flag.GlobalFlag.UseMock, "use-mock", false, "use mock (for development)") 53 | rootCmd.PersistentFlags().BoolVarP(&flag.GlobalFlag.Verbose, "verbose", "v", false, "verbose output") 54 | } 55 | -------------------------------------------------------------------------------- /cmd/util/log.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/database/dmodel" 5 | "github.com/rs/zerolog" 6 | "github.com/rs/zerolog/log" 7 | "os" 8 | "path" 9 | ) 10 | 11 | func LogError(err error) { 12 | if zerolog.GlobalLevel() <= zerolog.DebugLevel { 13 | log.Error().Msgf("%+v", err) 14 | } else { 15 | log.Error().Msg(err.Error()) 16 | } 17 | } 18 | 19 | func LogResultOutputPath(outputPath string) { 20 | wd, err := os.Getwd() 21 | if err == nil { 22 | log.Info().Msg("Result html is at: " + path.Join(wd, outputPath)) 23 | } 24 | } 25 | 26 | func LogNewIndexTargets(newIdxTargets []*dmodel.IndexTarget) { 27 | if len(newIdxTargets) > 0 { 28 | log.Debug().Msg("Found possibly efficient index combinations:") 29 | for i, it := range newIdxTargets { 30 | log.Printf("\t%d.%s", i, it.CombinationString()) 31 | } 32 | } else { 33 | log.Debug().Msg("No possibly efficient index found. Perhaps already indexed?") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mrasu/GravityR/injection" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var versionCmd = &cobra.Command{ 10 | Use: "version", 11 | Short: "Show version", 12 | Run: func(cmd *cobra.Command, args []string) { 13 | fmt.Printf("GravityR v%s, commit %s\n", injection.BinInfo.Version, injection.BinInfo.Commit) 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /database/dmodel/examination_index_result.go: -------------------------------------------------------------------------------- 1 | package dmodel 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/html/viewmodel" 5 | ) 6 | 7 | type ExaminationIndexResult struct { 8 | IndexTarget *IndexTarget 9 | ExecutionTimeMillis int64 10 | } 11 | 12 | func NewExaminationIndexResult(it *IndexTarget, time int64) *ExaminationIndexResult { 13 | return &ExaminationIndexResult{ 14 | IndexTarget: it, 15 | ExecutionTimeMillis: time, 16 | } 17 | } 18 | 19 | func (eir *ExaminationIndexResult) ToViewModel() *viewmodel.VmExaminationIndexResult { 20 | return &viewmodel.VmExaminationIndexResult{ 21 | ExecutionTimeMillis: eir.ExecutionTimeMillis, 22 | IndexTarget: eir.IndexTarget.ToViewModel(), 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /database/dmodel/examination_result.go: -------------------------------------------------------------------------------- 1 | package dmodel 2 | 3 | import "github.com/mrasu/GravityR/html/viewmodel" 4 | 5 | type ExaminationResult struct { 6 | OriginalTimeMillis int64 7 | IndexResults []*ExaminationIndexResult 8 | } 9 | 10 | func NewExaminationResult(irs []*ExaminationIndexResult, time int64) *ExaminationResult { 11 | return &ExaminationResult{ 12 | OriginalTimeMillis: time, 13 | IndexResults: irs, 14 | } 15 | } 16 | 17 | func (er *ExaminationResult) ToViewModel() *viewmodel.VmExaminationResult { 18 | var virs []*viewmodel.VmExaminationIndexResult 19 | for _, ir := range er.IndexResults { 20 | virs = append(virs, ir.ToViewModel()) 21 | } 22 | 23 | return &viewmodel.VmExaminationResult{ 24 | OriginalTimeMillis: er.OriginalTimeMillis, 25 | IndexResults: virs, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /database/dmodel/index_column.go: -------------------------------------------------------------------------------- 1 | package dmodel 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/html/viewmodel" 5 | "github.com/pkg/errors" 6 | "regexp" 7 | ) 8 | 9 | type IndexColumn struct { 10 | Name string 11 | } 12 | 13 | func NewIndexColumn(name string) (*IndexColumn, error) { 14 | if !wordOnlyReg.MatchString(name) { 15 | return nil, errors.Errorf("Including non word character. %s", name) 16 | } 17 | 18 | return &IndexColumn{Name: name}, nil 19 | } 20 | 21 | func (ic *IndexColumn) ToViewModel() *viewmodel.VmIndexColumn { 22 | return &viewmodel.VmIndexColumn{Name: ic.Name} 23 | } 24 | 25 | var nonAsciiReg = regexp.MustCompile(`\W`) 26 | 27 | func (ic *IndexColumn) SafeName() string { 28 | return nonAsciiReg.ReplaceAllString(ic.Name, "_") 29 | } 30 | 31 | func (ic *IndexColumn) Equals(other *IndexColumn) bool { 32 | return ic.Name == other.Name 33 | } 34 | -------------------------------------------------------------------------------- /database/dmodel/single_table_explain_result.go: -------------------------------------------------------------------------------- 1 | package dmodel 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type SingleTableExplainResult struct { 8 | TableName string 9 | 10 | EstimatedTotalTime float64 11 | } 12 | 13 | func (st *SingleTableExplainResult) String() string { 14 | txt := fmt.Sprintf( 15 | "SingleTableExplainResult(TableName: %s, EstimatedTotalTime: %f)", 16 | st.TableName, 17 | st.EstimatedTotalTime, 18 | ) 19 | return txt 20 | } 21 | -------------------------------------------------------------------------------- /database/dservice/build_single_table_explain_results.go: -------------------------------------------------------------------------------- 1 | package dservice 2 | 3 | import "github.com/mrasu/GravityR/database/dmodel" 4 | 5 | type ExplainNode interface { 6 | TableName() string 7 | EstimatedTotalTime() float64 8 | 9 | GetChildren() []ExplainNode 10 | } 11 | 12 | type singleTableTree struct { 13 | tableName string 14 | nodes []ExplainNode 15 | } 16 | 17 | type singleTableExplainResultsBuilder struct{} 18 | 19 | func BuildSingleTableExplainResults(root ExplainNode) []*dmodel.SingleTableExplainResult { 20 | var trees []*singleTableTree 21 | sb := singleTableExplainResultsBuilder{} 22 | sb.toSingleTableTreeRecursive(&trees, root, nil) 23 | 24 | var res []*dmodel.SingleTableExplainResult 25 | for _, tree := range trees { 26 | res = append(res, &dmodel.SingleTableExplainResult{ 27 | TableName: tree.tableName, 28 | EstimatedTotalTime: tree.nodes[0].EstimatedTotalTime(), 29 | }) 30 | } 31 | return res 32 | } 33 | 34 | func (sb *singleTableExplainResultsBuilder) toSingleTableTreeRecursive(trees *[]*singleTableTree, node ExplainNode, currentTree *singleTableTree) bool { 35 | treeUsed := false 36 | isRoot := false 37 | if currentTree == nil { 38 | if node.TableName() != "" { 39 | currentTree = &singleTableTree{ 40 | tableName: node.TableName(), 41 | nodes: []ExplainNode{node}, 42 | } 43 | isRoot = true 44 | } 45 | } else { 46 | if node.TableName() == currentTree.tableName { 47 | currentTree.nodes = append(currentTree.nodes, node) 48 | treeUsed = true 49 | } else { 50 | *trees = append(*trees, currentTree) 51 | 52 | if node.TableName() != "" { 53 | currentTree = &singleTableTree{ 54 | tableName: node.TableName(), 55 | nodes: []ExplainNode{node}, 56 | } 57 | isRoot = true 58 | } else { 59 | currentTree = nil 60 | } 61 | } 62 | } 63 | 64 | if len(node.GetChildren()) == 0 && isRoot { 65 | *trees = append(*trees, currentTree) 66 | return false 67 | } 68 | 69 | for _, c := range node.GetChildren() { 70 | used := sb.toSingleTableTreeRecursive(trees, c, currentTree) 71 | if used { 72 | if isRoot { 73 | *trees = append(*trees, currentTree) 74 | } 75 | currentTree = nil 76 | treeUsed = true 77 | } 78 | } 79 | 80 | return treeUsed 81 | } 82 | -------------------------------------------------------------------------------- /database/dservice/dparser/column_schema.go: -------------------------------------------------------------------------------- 1 | package dparser 2 | 3 | type ColumnSchema struct { 4 | Name string 5 | } 6 | -------------------------------------------------------------------------------- /database/dservice/dparser/field.go: -------------------------------------------------------------------------------- 1 | package dparser 2 | 3 | type Field struct { 4 | AsName string 5 | Columns []*FieldColumn 6 | } 7 | 8 | func (f *Field) Name() string { 9 | if f.AsName != "" { 10 | return f.AsName 11 | } else if len(f.Columns) == 1 { 12 | return f.Columns[0].Name 13 | } 14 | 15 | return "" 16 | } 17 | -------------------------------------------------------------------------------- /database/dservice/dparser/field_column.go: -------------------------------------------------------------------------------- 1 | package dparser 2 | 3 | type FieldColumn struct { 4 | // internal name to link with parent scope 5 | ReferenceName string 6 | 7 | Table string 8 | Name string 9 | Type FieldType 10 | } 11 | -------------------------------------------------------------------------------- /database/dservice/dparser/field_type.go: -------------------------------------------------------------------------------- 1 | package dparser 2 | 3 | type FieldType int 4 | 5 | const ( 6 | FieldReference FieldType = iota 7 | FieldCondition 8 | FieldAggregation 9 | FieldSubquery 10 | FieldStar 11 | ) 12 | -------------------------------------------------------------------------------- /database/dservice/dparser/index_field.go: -------------------------------------------------------------------------------- 1 | package dparser 2 | 3 | type IndexField struct { 4 | Name string 5 | Type FieldType 6 | } 7 | -------------------------------------------------------------------------------- /database/dservice/dparser/index_target_table.go: -------------------------------------------------------------------------------- 1 | package dparser 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mrasu/GravityR/database/dmodel" 6 | "github.com/mrasu/GravityR/lib" 7 | "sort" 8 | ) 9 | 10 | type IndexTargetTable struct { 11 | TableName string 12 | IndexFields []*IndexField 13 | } 14 | 15 | func (itt *IndexTargetTable) ToIndexTarget() *dmodel.IndexTarget { 16 | it := &dmodel.IndexTarget{ 17 | TableName: itt.TableName, 18 | } 19 | for _, f := range itt.IndexFields { 20 | it.Columns = append(it.Columns, &dmodel.IndexColumn{Name: f.Name}) 21 | } 22 | return it 23 | } 24 | 25 | func (itt *IndexTargetTable) String() string { 26 | txt := fmt.Sprintf( 27 | "IndexTargetTable(table: %s columns: %s)", 28 | itt.TableName, 29 | lib.Join(itt.IndexFields, ", ", func(f *IndexField) string { return f.Name }), 30 | ) 31 | return txt 32 | } 33 | 34 | func SortIndexTargetTable(tables []*IndexTargetTable) { 35 | sort.Slice(tables, func(i, j int) bool { 36 | if tables[i].TableName != tables[j].TableName { 37 | return tables[i].TableName < tables[j].TableName 38 | } 39 | 40 | iIndexes := tables[i].IndexFields 41 | jIndexes := tables[j].IndexFields 42 | if len(iIndexes) != len(jIndexes) { 43 | return len(iIndexes) < len(jIndexes) 44 | } 45 | 46 | for idx := 0; idx < len(iIndexes); idx++ { 47 | if iIndexes[idx].Name == jIndexes[idx].Name { 48 | continue 49 | } 50 | return iIndexes[idx].Name < jIndexes[idx].Name 51 | } 52 | 53 | return false 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /database/dservice/dparser/stmt_scope.go: -------------------------------------------------------------------------------- 1 | package dparser 2 | 3 | import "github.com/mrasu/GravityR/lib" 4 | 5 | const RootScopeName = "<root>" 6 | 7 | type StmtScope struct { 8 | Name string 9 | Fields []*Field 10 | FieldScopes []*StmtScope 11 | Tables []*Table 12 | SubScopes []*StmtScope 13 | CTEs map[string]*StmtScope 14 | } 15 | 16 | func (ss *StmtScope) ListAsTableMap() map[string]*lib.Set[string] { 17 | res := map[string]*lib.Set[string]{} 18 | for _, t := range ss.Tables { 19 | res[t.AsName] = lib.NewSetS([]string{t.Name}) 20 | } 21 | 22 | for _, s := range ss.SubScopes { 23 | asTableMap := s.ListAsTableMap() 24 | ts := lib.NewSet[string]() 25 | for name, tNames := range asTableMap { 26 | //TODO: consider scope to handle name duplication 27 | if s, ok := res[name]; ok { 28 | s.Merge(tNames) 29 | } else { 30 | res[name] = lib.NewSetS(tNames.Values()) 31 | } 32 | 33 | ts.Merge(tNames) 34 | } 35 | res[s.Name] = ts 36 | } 37 | 38 | delete(res, "") 39 | return res 40 | } 41 | -------------------------------------------------------------------------------- /database/dservice/dparser/table.go: -------------------------------------------------------------------------------- 1 | package dparser 2 | 3 | type Table struct { 4 | AsName string 5 | Name string 6 | IsLateral bool 7 | } 8 | 9 | func (t *Table) AsOrName() string { 10 | if t.AsName != "" { 11 | return t.AsName 12 | } else { 13 | return t.Name 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /database/dservice/dparser/table_schema.go: -------------------------------------------------------------------------------- 1 | package dparser 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mrasu/GravityR/lib" 6 | ) 7 | 8 | type TableSchema struct { 9 | Name string 10 | Columns []*ColumnSchema 11 | PrimaryKeys []string 12 | } 13 | 14 | func CreateTableSchemas[T any](tables []string, vals []T, f func(T) (string, string, bool)) []*TableSchema { 15 | ts := map[string]*TableSchema{} 16 | for _, v := range vals { 17 | var t *TableSchema 18 | tName, colName, isPK := f(v) 19 | if et, ok := ts[tName]; ok { 20 | t = et 21 | } else { 22 | t = &TableSchema{ 23 | Name: tName, 24 | } 25 | ts[tName] = t 26 | } 27 | 28 | t.Columns = append(t.Columns, &ColumnSchema{ 29 | Name: colName, 30 | }) 31 | 32 | if isPK { 33 | t.PrimaryKeys = append(t.PrimaryKeys, colName) 34 | } 35 | } 36 | 37 | var res []*TableSchema 38 | for _, table := range tables { 39 | if t, ok := ts[table]; ok { 40 | res = append(res, t) 41 | } else { 42 | res = append(res, nil) 43 | } 44 | } 45 | 46 | return res 47 | } 48 | 49 | func (ts *TableSchema) TableDescription() string { 50 | txt := fmt.Sprintf( 51 | "Table(name: %s, columns: [%s], primaryKeys: %s)", 52 | ts.Name, 53 | lib.Join(ts.Columns, ", ", func(c *ColumnSchema) string { return c.Name }), 54 | ts.PrimaryKeys, 55 | ) 56 | return txt 57 | } 58 | -------------------------------------------------------------------------------- /database/dservice/existing_index_remover.go: -------------------------------------------------------------------------------- 1 | package dservice 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/database/dmodel" 5 | "github.com/samber/lo" 6 | ) 7 | 8 | type IndexGetter interface { 9 | GetIndexes(database string, tables []string) ([]*dmodel.IndexTarget, error) 10 | } 11 | 12 | type ExistingIndexRemover struct { 13 | idxGetter IndexGetter 14 | idxTargets []*dmodel.IndexTarget 15 | dbName string 16 | } 17 | 18 | func NewExistingIndexRemover(idxGetter IndexGetter, dbName string, its []*dmodel.IndexTarget) *ExistingIndexRemover { 19 | return &ExistingIndexRemover{ 20 | idxGetter: idxGetter, 21 | idxTargets: its, 22 | dbName: dbName, 23 | } 24 | } 25 | 26 | func (r *ExistingIndexRemover) Remove() ([]*dmodel.IndexTarget, error) { 27 | idxTargets := lo.Uniq(r.idxTargets) 28 | 29 | tNames := lo.Map(idxTargets, func(it *dmodel.IndexTarget, _ int) string { return it.TableName }) 30 | tNames = lo.Uniq(tNames) 31 | 32 | existingIdxes, err := r.idxGetter.GetIndexes(r.dbName, tNames) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | its := r.removeMatchedIndexTargets(existingIdxes, idxTargets) 38 | 39 | return its, nil 40 | } 41 | 42 | func (r *ExistingIndexRemover) removeMatchedIndexTargets(idxInfos []*dmodel.IndexTarget, its []*dmodel.IndexTarget) []*dmodel.IndexTarget { 43 | idxGroups := lo.GroupBy(idxInfos, func(i *dmodel.IndexTarget) string { return i.TableName }) 44 | 45 | return lo.Filter(its, func(it *dmodel.IndexTarget, _ int) bool { 46 | if idxes, ok := idxGroups[it.TableName]; ok { 47 | for _, id := range idxes { 48 | if it.HasSameIdxColumns(id) { 49 | return false 50 | } 51 | } 52 | return true 53 | } else { 54 | return true 55 | } 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /database/hasura/hservice/index_examiner.go: -------------------------------------------------------------------------------- 1 | package hservice 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mrasu/GravityR/database/dmodel" 6 | "github.com/mrasu/GravityR/infra/hasura" 7 | "github.com/mrasu/GravityR/lib" 8 | "time" 9 | ) 10 | 11 | type IndexExaminer struct { 12 | cli *hasura.Client 13 | query string 14 | variables map[string]interface{} 15 | } 16 | 17 | func NewIndexExaminer(cli *hasura.Client, query string, v map[string]interface{}) *IndexExaminer { 18 | return &IndexExaminer{ 19 | cli: cli, 20 | query: query, 21 | variables: v, 22 | } 23 | } 24 | 25 | func (ie *IndexExaminer) Execute() (int64, error) { 26 | start := time.Now() 27 | err := ie.cli.QueryWithoutResult(ie.query, ie.variables) 28 | if err != nil { 29 | return 0, err 30 | } 31 | elapsed := time.Since(start) 32 | 33 | return elapsed.Milliseconds(), nil 34 | } 35 | 36 | func (ie *IndexExaminer) CreateIndex(name string, it *dmodel.IndexTarget) error { 37 | sql := fmt.Sprintf(`CREATE INDEX "%s" ON "%s" (%s)`, 38 | name, it.TableName, 39 | lib.Join(it.Columns, ",", func(i *dmodel.IndexColumn) string { return `"` + i.SafeName() + `"` }), 40 | ) 41 | _, err := ie.cli.RunRawSQL(sql) 42 | return err 43 | } 44 | 45 | func (ie *IndexExaminer) DropIndex(name string, it *dmodel.IndexTarget) error { 46 | sql := fmt.Sprintf(`DROP INDEX "%s"`, name) 47 | _, err := ie.cli.RunRawSQL(sql) 48 | return err 49 | } 50 | -------------------------------------------------------------------------------- /database/hasura/hservice/index_getter.go: -------------------------------------------------------------------------------- 1 | package hservice 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/database/dmodel" 5 | "github.com/mrasu/GravityR/infra/hasura" 6 | "github.com/samber/lo" 7 | ) 8 | 9 | type IndexGetter struct { 10 | cli *hasura.Client 11 | } 12 | 13 | func NewIndexGetter(cli *hasura.Client) *IndexGetter { 14 | return &IndexGetter{cli: cli} 15 | } 16 | 17 | func (ig *IndexGetter) GetIndexes(dbSchema string, tables []string) ([]*dmodel.IndexTarget, error) { 18 | infos, err := ig.cli.GetIndexes(dbSchema, tables) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | return lo.Map(infos, func(info *hasura.IndexInfo, _ int) *dmodel.IndexTarget { 24 | return dmodel.NewIndexTarget(info.TableName, info.Columns) 25 | }), nil 26 | } 27 | -------------------------------------------------------------------------------- /database/hasura/hservice/index_suggester.go: -------------------------------------------------------------------------------- 1 | package hservice 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/database/dmodel" 5 | "github.com/mrasu/GravityR/database/dservice" 6 | "github.com/mrasu/GravityR/database/hasura/hservice/parser" 7 | "github.com/mrasu/GravityR/database/postgres/pservice" 8 | "github.com/mrasu/GravityR/infra/hasura" 9 | ) 10 | 11 | type IndexSuggester struct { 12 | cli *hasura.Client 13 | } 14 | 15 | func NewIndexSuggester(cli *hasura.Client) *IndexSuggester { 16 | return &IndexSuggester{ 17 | cli: cli, 18 | } 19 | } 20 | 21 | func (is *IndexSuggester) Suggest(query string) ([]*dmodel.IndexTarget, error) { 22 | stmt, err := pservice.Parse(query) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | its, errs := parser.ListPossibleIndexes(is.cli, stmt) 28 | if len(errs) > 0 { 29 | return nil, errs[0] 30 | } 31 | 32 | its, err = is.removeExistingIndexTargets(its) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | return its, nil 38 | } 39 | 40 | func (is *IndexSuggester) removeExistingIndexTargets(its []*dmodel.IndexTarget) ([]*dmodel.IndexTarget, error) { 41 | idxGetter := NewIndexGetter(is.cli) 42 | res, err := dservice.NewExistingIndexRemover(idxGetter, "public", its).Remove() 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | return res, nil 48 | } 49 | -------------------------------------------------------------------------------- /database/hasura/hservice/parser/collect_table_schemas.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mrasu/GravityR/database/dservice/dparser" 6 | "github.com/mrasu/GravityR/infra/hasura" 7 | "github.com/mrasu/GravityR/lib" 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | func CollectTableSchemas(cli *hasura.Client, schema string, tables []string) ([]*dparser.TableSchema, error) { 12 | cols, err := cli.GetTableColumns(schema, tables) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | schemas := dparser.CreateTableSchemas(tables, cols, func(c *hasura.ColumnInfo) (string, string, bool) { 18 | return c.TableName, c.ColumnName, c.IsPK 19 | }) 20 | 21 | for i, table := range tables { 22 | if schemas[i] == nil { 23 | return nil, lib.NewUnsupportedError(fmt.Sprintf("unknown table found. perhaps using VIEW? not supporting: %s", table)) 24 | } 25 | } 26 | 27 | log.Debug().Msg("Table schemas:") 28 | for i, s := range schemas { 29 | log.Printf("\t%d. %s", i, s.TableDescription()) 30 | } 31 | 32 | return schemas, nil 33 | } 34 | -------------------------------------------------------------------------------- /database/hasura/hservice/parser/index.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | pParser "github.com/auxten/postgresql-parser/pkg/sql/parser" 5 | "github.com/mrasu/GravityR/database/dmodel" 6 | "github.com/mrasu/GravityR/database/dservice/dparser" 7 | "github.com/mrasu/GravityR/database/postgres/pservice/parser" 8 | "github.com/mrasu/GravityR/infra/hasura" 9 | ) 10 | 11 | func ListPossibleIndexes(cli *hasura.Client, stmt *pParser.Statement) ([]*dmodel.IndexTarget, []error) { 12 | tNames, errs := parser.CollectTableNames(stmt) 13 | if len(errs) > 0 { 14 | return nil, errs 15 | } 16 | tables, err := CollectTableSchemas(cli, "public", tNames) 17 | if err != nil { 18 | return nil, []error{err} 19 | } 20 | 21 | scopes, errs := parser.CollectStmtScopes(stmt, "public") 22 | if len(errs) > 0 { 23 | return nil, errs 24 | } 25 | 26 | its, err := dparser.NewIndexTargetBuilder(tables).Build(scopes) 27 | if err != nil { 28 | return nil, []error{err} 29 | } 30 | 31 | return its, nil 32 | } 33 | -------------------------------------------------------------------------------- /database/mysql/mmodel/analyze_result_line.go: -------------------------------------------------------------------------------- 1 | package mmodel 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // AnalyzeResultLine is the parsed information from `EXPLAIN ANALYZE` query 8 | // c.f) https://dev.mysql.com/doc/refman/8.0/en/explain.html#explain-analyze 9 | type AnalyzeResultLine struct { 10 | Text string 11 | TableName string 12 | 13 | EstimatedInitCost float64 14 | EstimatedCost float64 15 | EstimatedReturnedRows int 16 | 17 | ActualTimeFirstRow float64 18 | ActualTimeAvg float64 19 | ActualReturnedRows int 20 | ActualLoopCount int 21 | } 22 | 23 | func (l *AnalyzeResultLine) ActualTotalTime() float64 { 24 | return l.ActualTimeAvg * float64(l.ActualLoopCount) 25 | } 26 | 27 | func (l *AnalyzeResultLine) String() string { 28 | return fmt.Sprintf( 29 | "[%s] estimate_cost=%f, estimate_rows=%d, time=%f..%f, totalTime=%f, rows=%d, loops=%d\n%s", 30 | l.TableName, 31 | l.EstimatedCost, l.EstimatedReturnedRows, 32 | l.ActualTimeFirstRow, l.ActualTimeAvg, l.ActualTotalTime(), 33 | l.ActualReturnedRows, l.ActualLoopCount, 34 | l.Text, 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /database/mysql/mmodel/explain_analyze_tree.go: -------------------------------------------------------------------------------- 1 | package mmodel 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/database/dmodel" 5 | "github.com/mrasu/GravityR/database/dservice" 6 | "github.com/mrasu/GravityR/html/viewmodel" 7 | ) 8 | 9 | type ExplainAnalyzeTree struct { 10 | Root *ExplainAnalyzeTreeNode 11 | } 12 | 13 | func (eat *ExplainAnalyzeTree) ToSingleTableResults() []*dmodel.SingleTableExplainResult { 14 | return dservice.BuildSingleTableExplainResults(eat.Root) 15 | } 16 | 17 | func (eat *ExplainAnalyzeTree) ToViewModel() []*viewmodel.VmMysqlExplainAnalyzeNode { 18 | var res []*viewmodel.VmMysqlExplainAnalyzeNode 19 | for _, n := range eat.Root.Children { 20 | res = append(res, n.ToViewModel()) 21 | } 22 | return res 23 | } 24 | -------------------------------------------------------------------------------- /database/mysql/mmodel/explain_analyze_tree_node.go: -------------------------------------------------------------------------------- 1 | package mmodel 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/database/dservice" 5 | "github.com/mrasu/GravityR/html/viewmodel" 6 | ) 7 | 8 | type ExplainAnalyzeTreeNode struct { 9 | AnalyzeResultLine *ExplainAnalyzeResultLine 10 | Children []*ExplainAnalyzeTreeNode 11 | } 12 | 13 | func (n *ExplainAnalyzeTreeNode) ToViewModel() *viewmodel.VmMysqlExplainAnalyzeNode { 14 | vm := &viewmodel.VmMysqlExplainAnalyzeNode{ 15 | Text: n.AnalyzeResultLine.Text, 16 | Title: n.AnalyzeResultLine.Title(), 17 | TableName: n.AnalyzeResultLine.TableName, 18 | EstimatedInitCost: n.AnalyzeResultLine.EstimatedInitCost, 19 | EstimatedCost: n.AnalyzeResultLine.EstimatedCost, 20 | EstimatedReturnedRows: n.AnalyzeResultLine.EstimatedReturnedRows, 21 | ActualTimeFirstRow: n.AnalyzeResultLine.ActualTimeFirstRow, 22 | ActualTimeAvg: n.AnalyzeResultLine.ActualTimeAvg, 23 | ActualReturnedRows: n.AnalyzeResultLine.ActualReturnedRows, 24 | ActualLoopCount: n.AnalyzeResultLine.ActualLoopCount, 25 | } 26 | for _, c := range n.Children { 27 | vm.Children = append(vm.Children, c.ToViewModel()) 28 | } 29 | 30 | return vm 31 | } 32 | 33 | func (n *ExplainAnalyzeTreeNode) TableName() string { 34 | return n.AnalyzeResultLine.TableName 35 | } 36 | 37 | func (n *ExplainAnalyzeTreeNode) EstimatedTotalTime() float64 { 38 | return n.AnalyzeResultLine.EstimatedTotalTime() 39 | } 40 | 41 | func (n *ExplainAnalyzeTreeNode) GetChildren() []dservice.ExplainNode { 42 | var res []dservice.ExplainNode 43 | for _, c := range n.Children { 44 | res = append(res, c) 45 | } 46 | return res 47 | } 48 | -------------------------------------------------------------------------------- /database/mysql/mservice/explainer.go: -------------------------------------------------------------------------------- 1 | package mservice 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/database/mysql/mmodel" 5 | "github.com/mrasu/GravityR/infra/mysql" 6 | "github.com/mrasu/GravityR/lib" 7 | "github.com/pkg/errors" 8 | "strings" 9 | ) 10 | 11 | type Explainer struct { 12 | db *mysql.DB 13 | } 14 | 15 | func NewExplainer(db *mysql.DB) *Explainer { 16 | return &Explainer{db: db} 17 | } 18 | 19 | func (e *Explainer) ExplainWithAnalyze(query string) (*mmodel.ExplainAnalyzeTree, error) { 20 | explainLine, err := e.db.ExplainWithAnalyze(query) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | root, err := e.buildExplainNode(explainLine) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return &mmodel.ExplainAnalyzeTree{Root: root}, nil 31 | } 32 | 33 | func (e *Explainer) buildExplainNode(explainLine string) (*mmodel.ExplainAnalyzeTreeNode, error) { 34 | nodeStack := lib.NewStack[mmodel.ExplainAnalyzeTreeNode]() 35 | 36 | root := &mmodel.ExplainAnalyzeTreeNode{AnalyzeResultLine: &mmodel.ExplainAnalyzeResultLine{}} 37 | nodeStack.Push(root) 38 | treeLines := strings.Split(explainLine, "\n") 39 | for _, line := range treeLines { 40 | if line == "" { 41 | continue 42 | } 43 | 44 | nest, l, err := mmodel.ParseExplainAnalyzeResultLine(line) 45 | if err != nil { 46 | return nil, err 47 | } 48 | n := &mmodel.ExplainAnalyzeTreeNode{ 49 | AnalyzeResultLine: l, 50 | Children: nil, 51 | } 52 | if nodeStack.Size()-1 == nest { 53 | currentNode := nodeStack.Top() 54 | currentNode.Children = append(currentNode.Children, n) 55 | } else if nodeStack.Size()-1 > nest { 56 | nodeStack.Pop() 57 | for nodeStack.Size()-1 > nest { 58 | nodeStack.Pop() 59 | } 60 | currentNode := nodeStack.Top() 61 | currentNode.Children = append(currentNode.Children, n) 62 | } else { 63 | return nil, errors.New("invalid result from EXPLAIN ANALYZE") 64 | } 65 | 66 | nodeStack.Push(n) 67 | } 68 | 69 | return root, nil 70 | } 71 | -------------------------------------------------------------------------------- /database/mysql/mservice/index_examiner.go: -------------------------------------------------------------------------------- 1 | package mservice 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mrasu/GravityR/database/dmodel" 6 | "github.com/mrasu/GravityR/infra/mysql" 7 | "github.com/mrasu/GravityR/lib" 8 | "time" 9 | ) 10 | 11 | type IndexExaminer struct { 12 | db *mysql.DB 13 | query string 14 | } 15 | 16 | func NewIndexExaminer(db *mysql.DB, query string) *IndexExaminer { 17 | return &IndexExaminer{ 18 | db: db, 19 | query: query, 20 | } 21 | } 22 | 23 | func (ie *IndexExaminer) Execute() (int64, error) { 24 | start := time.Now() 25 | _, err := ie.db.Exec(ie.query) 26 | if err != nil { 27 | return 0, err 28 | } 29 | elapsed := time.Since(start) 30 | 31 | return elapsed.Milliseconds(), nil 32 | } 33 | 34 | func (ie *IndexExaminer) CreateIndex(name string, it *dmodel.IndexTarget) error { 35 | sql := fmt.Sprintf( 36 | "ALTER TABLE `%s` ADD INDEX `%s` (%s)", 37 | it.TableName, name, 38 | lib.Join(it.Columns, ",", func(i *dmodel.IndexColumn) string { return "`" + i.SafeName() + "`" }), 39 | ) 40 | _, err := ie.db.Exec(sql) 41 | return err 42 | } 43 | 44 | func (ie *IndexExaminer) DropIndex(name string, it *dmodel.IndexTarget) error { 45 | sql := fmt.Sprintf("ALTER TABLE `%s` DROP INDEX `%s`", it.TableName, name) 46 | _, err := ie.db.Exec(sql) 47 | return err 48 | } 49 | -------------------------------------------------------------------------------- /database/mysql/mservice/index_getter.go: -------------------------------------------------------------------------------- 1 | package mservice 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/database/dmodel" 5 | "github.com/mrasu/GravityR/infra/mysql" 6 | "github.com/samber/lo" 7 | ) 8 | 9 | type IndexGetter struct { 10 | db *mysql.DB 11 | } 12 | 13 | func NewIndexGetter(db *mysql.DB) *IndexGetter { 14 | return &IndexGetter{db: db} 15 | } 16 | 17 | func (ig *IndexGetter) GetIndexes(dbName string, tables []string) ([]*dmodel.IndexTarget, error) { 18 | infos, err := ig.db.GetIndexes(dbName, tables) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | return lo.Map(infos, func(info *mysql.IndexInfo, _ int) *dmodel.IndexTarget { 24 | return dmodel.NewIndexTarget(info.TableName, info.Columns) 25 | }), nil 26 | } 27 | -------------------------------------------------------------------------------- /database/mysql/mservice/index_suggester.go: -------------------------------------------------------------------------------- 1 | package mservice 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/database/dmodel" 5 | "github.com/mrasu/GravityR/database/dservice" 6 | "github.com/mrasu/GravityR/database/mysql/mservice/parser" 7 | "github.com/mrasu/GravityR/infra/mysql" 8 | tParser "github.com/pingcap/tidb/parser" 9 | ) 10 | 11 | type IndexSuggester struct { 12 | db *mysql.DB 13 | dbName string 14 | } 15 | 16 | func NewIndexSuggester(db *mysql.DB, dbName string) *IndexSuggester { 17 | return &IndexSuggester{ 18 | db: db, 19 | dbName: dbName, 20 | } 21 | } 22 | 23 | func (is *IndexSuggester) Suggest(query string) ([]*dmodel.IndexTarget, error) { 24 | p := tParser.New() 25 | rootNode, err := Parse(p, query) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | its, errs := parser.ListPossibleIndexes(is.db, is.dbName, rootNode) 31 | if len(errs) > 0 { 32 | return nil, errs[0] 33 | } 34 | 35 | its, err = is.removeExistingIndexTargets(its) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | return its, nil 41 | } 42 | 43 | func (is *IndexSuggester) removeExistingIndexTargets(its []*dmodel.IndexTarget) ([]*dmodel.IndexTarget, error) { 44 | idxGetter := NewIndexGetter(is.db) 45 | res, err := dservice.NewExistingIndexRemover(idxGetter, is.dbName, its).Remove() 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | return res, nil 51 | } 52 | -------------------------------------------------------------------------------- /database/mysql/mservice/parse.go: -------------------------------------------------------------------------------- 1 | package mservice 2 | 3 | import ( 4 | "github.com/pingcap/tidb/parser" 5 | "github.com/pingcap/tidb/parser/ast" 6 | ) 7 | 8 | func Parse(p *parser.Parser, query string) (ast.StmtNode, error) { 9 | stmtNodes, _, err := p.Parse(query, "", "") 10 | if err != nil { 11 | return nil, err 12 | } 13 | 14 | return stmtNodes[0], nil 15 | } 16 | -------------------------------------------------------------------------------- /database/mysql/mservice/parser/collect_table_schemas.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mrasu/GravityR/database/dservice/dparser" 6 | "github.com/mrasu/GravityR/infra/mysql" 7 | "github.com/mrasu/GravityR/lib" 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | func CollectTableSchemas(db *mysql.DB, dbName string, tables []string) ([]*dparser.TableSchema, error) { 12 | cols, err := db.GetTableColumns(dbName, tables) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | schemas := dparser.CreateTableSchemas(tables, cols, func(c *mysql.ColumnInfo) (string, string, bool) { 18 | return c.TableName, c.ColumnName, c.ColumnKey == "PRI" 19 | }) 20 | 21 | for i, table := range tables { 22 | if schemas[i] == nil { 23 | return nil, lib.NewUnsupportedError(fmt.Sprintf("unknown table found. perhaps using VIEW? not supporting: %s", table)) 24 | } 25 | } 26 | 27 | log.Debug().Msg("Table schemas:") 28 | for i, s := range schemas { 29 | log.Printf("\t%d. %s", i, s.TableDescription()) 30 | } 31 | 32 | return schemas, nil 33 | } 34 | -------------------------------------------------------------------------------- /database/mysql/mservice/parser/errors.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pingcap/tidb/parser/ast" 6 | "github.com/pkg/errors" 7 | "reflect" 8 | ) 9 | 10 | type UnexpectedChildError struct { 11 | parent ast.Node 12 | child ast.Node 13 | } 14 | 15 | func NewUnexpectedChildError(p, c ast.Node) error { 16 | return errors.Wrap(&UnexpectedChildError{p, c}, "") 17 | } 18 | 19 | func (uc *UnexpectedChildError) Error() string { 20 | return fmt.Sprintf("parent: %s, child: %s", reflect.TypeOf(uc.parent).Name(), reflect.TypeOf(uc.child).Name()) 21 | } 22 | -------------------------------------------------------------------------------- /database/mysql/mservice/parser/index.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/database/dmodel" 5 | "github.com/mrasu/GravityR/database/dservice/dparser" 6 | "github.com/mrasu/GravityR/infra/mysql" 7 | "github.com/pingcap/tidb/parser/ast" 8 | ) 9 | 10 | func ListPossibleIndexes(db *mysql.DB, dbName string, rootNode ast.StmtNode) ([]*dmodel.IndexTarget, []error) { 11 | tNames, errs := CollectTableNames(rootNode) 12 | if len(errs) > 0 { 13 | return nil, errs 14 | } 15 | 16 | tables, err := CollectTableSchemas(db, dbName, tNames) 17 | if err != nil { 18 | return nil, []error{err} 19 | } 20 | 21 | scopes, errs := CollectStmtScopes(rootNode) 22 | if len(errs) > 0 { 23 | return nil, errs 24 | } 25 | 26 | its, err := dparser.NewIndexTargetBuilder(tables).Build(scopes) 27 | if err != nil { 28 | return nil, []error{err} 29 | } 30 | 31 | return its, nil 32 | } 33 | -------------------------------------------------------------------------------- /database/postgres/pmodel/explain_analyze_tree.go: -------------------------------------------------------------------------------- 1 | package pmodel 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/database/dmodel" 5 | "github.com/mrasu/GravityR/database/dservice" 6 | "github.com/mrasu/GravityR/html/viewmodel" 7 | ) 8 | 9 | type ExplainAnalyzeTree struct { 10 | Root *ExplainAnalyzeTreeNode 11 | SummaryText string 12 | } 13 | 14 | func (eat *ExplainAnalyzeTree) ToSingleTableResults() []*dmodel.SingleTableExplainResult { 15 | return dservice.BuildSingleTableExplainResults(eat.Root) 16 | } 17 | 18 | func (eat *ExplainAnalyzeTree) ToViewModel() []*viewmodel.VmPostgresExplainAnalyzeNode { 19 | var res []*viewmodel.VmPostgresExplainAnalyzeNode 20 | for _, n := range eat.Root.Children { 21 | res = append(res, n.ToViewModel()) 22 | } 23 | return res 24 | } 25 | -------------------------------------------------------------------------------- /database/postgres/pmodel/explain_analyze_tree_node.go: -------------------------------------------------------------------------------- 1 | package pmodel 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/database/dservice" 5 | "github.com/mrasu/GravityR/html/viewmodel" 6 | "strings" 7 | ) 8 | 9 | type ExplainAnalyzeTreeNode struct { 10 | AnalyzeResultNode *ExplainAnalyzeResultNode 11 | Children []*ExplainAnalyzeTreeNode 12 | SpaceSize int 13 | } 14 | 15 | func (n *ExplainAnalyzeTreeNode) ToViewModel() *viewmodel.VmPostgresExplainAnalyzeNode { 16 | text := strings.Join(n.AnalyzeResultNode.Lines, "\n") 17 | 18 | vm := &viewmodel.VmPostgresExplainAnalyzeNode{ 19 | Text: text, 20 | Title: n.AnalyzeResultNode.Title(), 21 | TableName: n.AnalyzeResultNode.TableName, 22 | EstimatedInitCost: n.AnalyzeResultNode.EstimatedInitCost, 23 | EstimatedCost: n.AnalyzeResultNode.EstimatedCost, 24 | EstimatedReturnedRows: n.AnalyzeResultNode.EstimatedReturnedRows, 25 | EstimatedWidth: n.AnalyzeResultNode.EstimatedWidth, 26 | ActualTimeFirstRow: n.AnalyzeResultNode.ActualTimeFirstRow, 27 | ActualTimeAvg: n.AnalyzeResultNode.ActualTimeAvg, 28 | ActualReturnedRows: n.AnalyzeResultNode.ActualReturnedRows, 29 | ActualLoopCount: n.AnalyzeResultNode.ActualLoopCount, 30 | } 31 | for _, c := range n.Children { 32 | vm.Children = append(vm.Children, c.ToViewModel()) 33 | } 34 | 35 | return vm 36 | } 37 | 38 | func (n *ExplainAnalyzeTreeNode) TableName() string { 39 | return n.AnalyzeResultNode.TableName 40 | } 41 | 42 | func (n *ExplainAnalyzeTreeNode) EstimatedTotalTime() float64 { 43 | return n.AnalyzeResultNode.EstimatedTotalTime() 44 | } 45 | 46 | func (n *ExplainAnalyzeTreeNode) GetChildren() []dservice.ExplainNode { 47 | var res []dservice.ExplainNode 48 | for _, c := range n.Children { 49 | res = append(res, c) 50 | } 51 | return res 52 | } 53 | -------------------------------------------------------------------------------- /database/postgres/pservice/explainer.go: -------------------------------------------------------------------------------- 1 | package pservice 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/database/postgres/pmodel" 5 | "github.com/mrasu/GravityR/infra/postgres" 6 | ) 7 | 8 | type Explainer struct { 9 | db *postgres.DB 10 | } 11 | 12 | func NewExplainer(db *postgres.DB) *Explainer { 13 | return &Explainer{ 14 | db: db, 15 | } 16 | } 17 | 18 | func (e *Explainer) ExplainWithAnalyze(query string) (*pmodel.ExplainAnalyzeTree, error) { 19 | explainLines, err := e.db.ExplainWithAnalyze(query) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | return NewExplainAnalyzeTreeBuilder().Build(explainLines) 25 | } 26 | -------------------------------------------------------------------------------- /database/postgres/pservice/index_examiner.go: -------------------------------------------------------------------------------- 1 | package pservice 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mrasu/GravityR/database/dmodel" 6 | "github.com/mrasu/GravityR/infra/postgres" 7 | "github.com/mrasu/GravityR/lib" 8 | "time" 9 | ) 10 | 11 | type IndexExaminer struct { 12 | db *postgres.DB 13 | query string 14 | } 15 | 16 | func NewIndexExaminer(db *postgres.DB, query string) *IndexExaminer { 17 | return &IndexExaminer{ 18 | db: db, 19 | query: query, 20 | } 21 | } 22 | 23 | func (ie *IndexExaminer) Execute() (int64, error) { 24 | start := time.Now() 25 | _, err := ie.db.Exec(ie.query) 26 | if err != nil { 27 | return 0, err 28 | } 29 | elapsed := time.Since(start) 30 | 31 | return elapsed.Milliseconds(), nil 32 | } 33 | 34 | func (ie *IndexExaminer) CreateIndex(name string, it *dmodel.IndexTarget) error { 35 | sql := fmt.Sprintf(`CREATE INDEX "%s" ON "%s" (%s)`, 36 | name, it.TableName, 37 | lib.Join(it.Columns, ",", func(i *dmodel.IndexColumn) string { return `"` + i.SafeName() + `"` }), 38 | ) 39 | _, err := ie.db.Exec(sql) 40 | return err 41 | } 42 | 43 | func (ie *IndexExaminer) DropIndex(name string, it *dmodel.IndexTarget) error { 44 | sql := fmt.Sprintf(`DROP INDEX "%s"`, name) 45 | _, err := ie.db.Exec(sql) 46 | return err 47 | } 48 | -------------------------------------------------------------------------------- /database/postgres/pservice/index_getter.go: -------------------------------------------------------------------------------- 1 | package pservice 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/database/dmodel" 5 | "github.com/mrasu/GravityR/infra/postgres" 6 | "github.com/samber/lo" 7 | ) 8 | 9 | type IndexGetter struct { 10 | db *postgres.DB 11 | } 12 | 13 | func NewIndexGetter(db *postgres.DB) *IndexGetter { 14 | return &IndexGetter{db: db} 15 | } 16 | 17 | func (ig *IndexGetter) GetIndexes(dbSchema string, tables []string) ([]*dmodel.IndexTarget, error) { 18 | infos, err := ig.db.GetIndexes(dbSchema, tables) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | return lo.Map(infos, func(info *postgres.IndexInfo, _ int) *dmodel.IndexTarget { 24 | return dmodel.NewIndexTarget(info.TableName, info.Columns) 25 | }), nil 26 | } 27 | -------------------------------------------------------------------------------- /database/postgres/pservice/index_suggester.go: -------------------------------------------------------------------------------- 1 | package pservice 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/database/dmodel" 5 | "github.com/mrasu/GravityR/database/dservice" 6 | "github.com/mrasu/GravityR/database/postgres/pservice/parser" 7 | "github.com/mrasu/GravityR/infra/postgres" 8 | ) 9 | 10 | type IndexSuggester struct { 11 | db *postgres.DB 12 | schema string 13 | } 14 | 15 | func NewIndexSuggester(db *postgres.DB, schema string) *IndexSuggester { 16 | return &IndexSuggester{ 17 | db: db, 18 | schema: schema, 19 | } 20 | } 21 | 22 | func (is *IndexSuggester) Suggest(query string) ([]*dmodel.IndexTarget, error) { 23 | stmt, err := Parse(query) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | its, errs := parser.ListPossibleIndexes(is.db, is.schema, stmt) 29 | if len(errs) > 0 { 30 | return nil, errs[0] 31 | } 32 | 33 | its, err = is.removeExistingIndexTargets(is.db, is.schema, its) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return its, nil 39 | } 40 | 41 | func (is *IndexSuggester) removeExistingIndexTargets(db *postgres.DB, dbName string, its []*dmodel.IndexTarget) ([]*dmodel.IndexTarget, error) { 42 | idxGetter := NewIndexGetter(db) 43 | res, err := dservice.NewExistingIndexRemover(idxGetter, dbName, its).Remove() 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | return res, nil 49 | } 50 | -------------------------------------------------------------------------------- /database/postgres/pservice/parse.go: -------------------------------------------------------------------------------- 1 | package pservice 2 | 3 | import ( 4 | "github.com/auxten/postgresql-parser/pkg/sql/parser" 5 | "github.com/pkg/errors" 6 | ) 7 | 8 | func Parse(query string) (*parser.Statement, error) { 9 | stmts, err := parser.Parse(query) 10 | if err != nil { 11 | return nil, errors.Wrap(err, "failed to parse sql") 12 | } 13 | 14 | if len(stmts) > 1 { 15 | return nil, errors.New("not supporting query having multiple statements") 16 | } 17 | return &stmts[0], nil 18 | } 19 | -------------------------------------------------------------------------------- /database/postgres/pservice/parser/collect_table_schemas.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mrasu/GravityR/database/dservice/dparser" 6 | "github.com/mrasu/GravityR/infra/postgres" 7 | "github.com/mrasu/GravityR/lib" 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | func CollectTableSchemas(db *postgres.DB, schema string, tables []string) ([]*dparser.TableSchema, error) { 12 | cols, err := db.GetTableColumns(schema, tables) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | schemas := dparser.CreateTableSchemas(tables, cols, func(c *postgres.ColumnInfo) (string, string, bool) { 18 | return c.TableName, c.ColumnName, c.IsPK 19 | }) 20 | 21 | for i, table := range tables { 22 | if schemas[i] == nil { 23 | return nil, lib.NewUnsupportedError(fmt.Sprintf("unknown table found. perhaps using VIEW? not supporting: %s", table)) 24 | } 25 | } 26 | 27 | log.Debug().Msg("Table schemas:") 28 | for i, s := range schemas { 29 | log.Printf("\t%d. %s", i, s.TableDescription()) 30 | } 31 | 32 | return schemas, nil 33 | } 34 | -------------------------------------------------------------------------------- /database/postgres/pservice/parser/index.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "github.com/auxten/postgresql-parser/pkg/sql/parser" 5 | "github.com/mrasu/GravityR/database/dmodel" 6 | "github.com/mrasu/GravityR/database/dservice/dparser" 7 | "github.com/mrasu/GravityR/infra/postgres" 8 | ) 9 | 10 | func ListPossibleIndexes(db *postgres.DB, schema string, stmt *parser.Statement) ([]*dmodel.IndexTarget, []error) { 11 | tNames, errs := CollectTableNames(stmt) 12 | if len(errs) > 0 { 13 | return nil, errs 14 | } 15 | 16 | tables, err := CollectTableSchemas(db, schema, tNames) 17 | if err != nil { 18 | return nil, []error{err} 19 | } 20 | 21 | scopes, errs := CollectStmtScopes(stmt, "public") 22 | if len(errs) > 0 { 23 | return nil, errs 24 | } 25 | 26 | its, err := dparser.NewIndexTargetBuilder(tables).Build(scopes) 27 | if err != nil { 28 | return nil, []error{err} 29 | } 30 | 31 | return its, nil 32 | } 33 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | mysql8: 5 | image: mysql:8.0.23 6 | volumes: 7 | - ./docker/mysql8/data:/var/lib/mysql 8 | ports: 9 | - "3306:3306" 10 | environment: 11 | MYSQL_DATABASE: gravityr 12 | MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' 13 | postgres14: 14 | profiles: [all] 15 | image: postgres:14 16 | volumes: 17 | - ./docker/postgres14/data:/var/lib/postgresql/data 18 | environment: 19 | POSTGRES_DB: gravityr 20 | POSTGRES_USER: root 21 | POSTGRES_HOST_AUTH_METHOD: trust 22 | # Uncomment to see show all queries 23 | # command: ["postgres", "-c", "log_statement=all"] 24 | ports: 25 | - "5432:5432" 26 | aws_mock: 27 | profiles: [all] 28 | build: 29 | context: ./docker/aws_mock 30 | volumes: 31 | - ./docker/aws_mock/fastapi:/app 32 | ports: 33 | - "8080:8080" 34 | jaeger_mock: 35 | profiles: [all] 36 | build: 37 | context: ./docker/jaeger_mock 38 | volumes: 39 | - ./docker/jaeger_mock/mock:/app 40 | ports: 41 | - "16685:16685" 42 | restart: unless-stopped 43 | hasura2: 44 | profiles: [all] 45 | image: hasura/graphql-engine:v2.10.0 46 | ports: 47 | - "8081:8080" 48 | depends_on: 49 | - postgres14 50 | restart: unless-stopped 51 | environment: 52 | HASURA_GRAPHQL_DATABASE_URL: postgres://root:@postgres14:5432/gravityr 53 | HASURA_GRAPHQL_ENABLE_CONSOLE: 'true' 54 | HASURA_GRAPHQL_ADMIN_SECRET: myadminsecretkey 55 | node: 56 | profiles: [all] 57 | build: 58 | context: ./docker/node 59 | command: ["tail", "-f", "/dev/null"] 60 | volumes: 61 | - ./docker/node/hasura:/hasura 62 | environment: 63 | HASURA_GRAPHQL_ENABLE_TELEMETRY: 'false' 64 | HASURA_GRAPHQL_ADMIN_SECRET: myadminsecretkey 65 | 66 | -------------------------------------------------------------------------------- /docker/aws_mock/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10 2 | 3 | WORKDIR /app 4 | COPY ./fastapi/requirements.txt . 5 | RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt 6 | 7 | CMD ["uvicorn", "main:app", "--reload", "--host", "0.0.0.0", "--port", "8080"] 8 | -------------------------------------------------------------------------------- /docker/aws_mock/fastapi/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Header, Response, Request 2 | from starlette.responses import JSONResponse 3 | 4 | from pi import build_pi_get_resource_metrics_response 5 | from rds import build_rds_describe_db_instances_response 6 | 7 | app = FastAPI() 8 | 9 | @app.get("/") 10 | def read_root(): 11 | return {"pong": "hello"} 12 | 13 | @app.post("/") 14 | async def read_aws(request: Request, content_type: str = Header(default="")): 15 | action = await get_action(request) 16 | if action == "PerformanceInsightsv20180227.GetResourceMetrics": 17 | return await build_pi_get_resource_metrics_response(request) 18 | elif action == "DescribeDBInstances": 19 | return build_rds_describe_db_instances_response() 20 | else: 21 | return build_unknown_action_response(content_type, action) 22 | 23 | async def get_action(request: Request) -> str: 24 | x_amz_target = request.headers.get("X-Amz-Target", "") 25 | form = await request.form() 26 | return form.get("Action", x_amz_target) 27 | 28 | def build_unknown_action_response(content_type: str, action: str) -> Response: 29 | if content_type == "application/x-www-form-urlencoded": 30 | content = f""" 31 | <ErrorResponse xmlns="http://rds.amazonaws.com/doc/2014-10-31/"> 32 | <Error> 33 | <Type>Sender</Type> 34 | <Code>UnknownAction</Code> 35 | <Message>Unknown Action: {action}</Message> 36 | </Error> 37 | <RequestId>799fa280-3701-465f-8b26-49edcf814748</RequestId> 38 | </ErrorResponse> 39 | """ 40 | return Response(status_code=400, content=content, media_type="text/xml") 41 | else: 42 | detail = {"__type": "UnknownTargetException","Message": f"Unknown X-Amz-Target: {action}"} 43 | return JSONResponse(status_code=400, content=detail) 44 | -------------------------------------------------------------------------------- /docker/aws_mock/fastapi/rds.py: -------------------------------------------------------------------------------- 1 | from fastapi import Response 2 | 3 | def build_rds_describe_db_instances_response() -> Response: 4 | with open("./data/rds/DescribeDbInstances.xml") as f: 5 | content = f.read() 6 | return Response(content=content, media_type="text/xml") 7 | -------------------------------------------------------------------------------- /docker/aws_mock/fastapi/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | uvicorn 3 | python-multipart 4 | -------------------------------------------------------------------------------- /docker/go.mod: -------------------------------------------------------------------------------- 1 | module fake_go_module // Exclude this directory from Go tools 2 | -------------------------------------------------------------------------------- /docker/jaeger_mock/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19 2 | WORKDIR /app 3 | CMD ls 4 | 5 | COPY ./mock/go.mod . 6 | COPY ./mock/go.sum . 7 | 8 | RUN go mod download 9 | 10 | CMD ["go", "run", ".", "serve"] 11 | -------------------------------------------------------------------------------- /docker/jaeger_mock/mock/mock_dependency_reader.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/jaegertracing/jaeger/model" 7 | "time" 8 | ) 9 | 10 | type mockDependencyReader struct{} 11 | 12 | func (m *mockDependencyReader) GetDependencies(context.Context, time.Time, time.Duration) ([]model.DependencyLink, error) { 13 | return nil, errors.New("unexpected call: GetDependencies") 14 | } 15 | -------------------------------------------------------------------------------- /docker/jaeger_mock/mock/mockdata/01_simple_trace.json: -------------------------------------------------------------------------------- 1 | { 2 | "Spans": [ 3 | { 4 | "TraceID": "1", 5 | "SpanID": "1", 6 | "OperationName": "request", 7 | "StartTime": "2001-02-03T01:05:06Z", 8 | "DurationMilliSec": 1000, 9 | "Tags": { 10 | "foo": "bar" 11 | }, 12 | "ServiceName": "bff", 13 | "SpanRef": { 14 | "TraceID": "1", 15 | "SpanID": "0000000000000000" 16 | } 17 | }, 18 | { 19 | "TraceID": "1", 20 | "SpanID": "2", 21 | "OperationName": "getUser", 22 | "StartTime": "2001-02-03T01:05:06Z", 23 | "DurationMilliSec": 1000, 24 | "Tags": { 25 | "foo": "buz" 26 | }, 27 | "ServiceName": "user", 28 | "SpanRef": { 29 | "TraceID": "1", 30 | "SpanID": "1" 31 | } 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /docker/jaeger_mock/mock/mockdata/02_slow_trace.json: -------------------------------------------------------------------------------- 1 | { 2 | "Spans": [ 3 | { 4 | "TraceID": "2", 5 | "SpanID": "1", 6 | "OperationName": "request", 7 | "StartTime": "2001-02-03T02:05:06Z", 8 | "DurationMilliSec": 120000, 9 | "Tags": { 10 | "foo": "bar" 11 | }, 12 | "ServiceName": "bff", 13 | "SpanRef": { 14 | "TraceID": "2", 15 | "SpanID": "0000000000000000" 16 | } 17 | }, 18 | { 19 | "TraceID": "2", 20 | "SpanID": "2", 21 | "OperationName": "getUser", 22 | "StartTime": "2001-02-03T02:05:06Z", 23 | "DurationMilliSec": 120000, 24 | "Tags": { 25 | "foo": "buz" 26 | }, 27 | "ServiceName": "user", 28 | "SpanRef": { 29 | "TraceID": "2", 30 | "SpanID": "1" 31 | } 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /docker/jaeger_mock/mock/mockdata/04_slow_trace_no_service.json: -------------------------------------------------------------------------------- 1 | { 2 | "Spans": [ 3 | { 4 | "TraceID": "4", 5 | "SpanID": "1", 6 | "OperationName": "request", 7 | "StartTime": "2001-02-03T04:05:06Z", 8 | "DurationMilliSec": 120000, 9 | "Tags": { 10 | "foo": "bar" 11 | }, 12 | "ServiceName": "bff", 13 | "SpanRef": { 14 | "TraceID": "4", 15 | "SpanID": "0000000000000000" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /docker/jaeger_mock/mock/mockdata/06_same_db_system.json: -------------------------------------------------------------------------------- 1 | { 2 | "Spans": [ 3 | { 4 | "TraceID": "6", 5 | "SpanID": "1", 6 | "OperationName": "request", 7 | "StartTime": "2001-02-03T06:05:06Z", 8 | "DurationMilliSec": 1230000, 9 | "Tags": { 10 | "foo": "bar" 11 | }, 12 | "ServiceName": "bff", 13 | "SpanRef": { 14 | "TraceID": "6", 15 | "SpanID": "0000000000000000" 16 | } 17 | }, 18 | { 19 | "TraceID": "6", 20 | "SpanID": "2", 21 | "OperationName": "get user", 22 | "StartTime": "2001-02-03T06:05:06Z", 23 | "DurationMilliSec": 10000, 24 | "Tags": { 25 | "foo": "bar" 26 | }, 27 | "ServiceName": "user", 28 | "SpanRef": { 29 | "TraceID": "6", 30 | "SpanID": "1" 31 | } 32 | }, 33 | { 34 | "TraceID": "6", 35 | "SpanID": "3", 36 | "OperationName": "query", 37 | "StartTime": "2001-02-03T06:05:06Z", 38 | "DurationMilliSec": 10000, 39 | "Tags": { 40 | "db.system": "mysql" 41 | }, 42 | "ServiceName": "user", 43 | "SpanRef": { 44 | "TraceID": "6", 45 | "SpanID": "2" 46 | } 47 | }, 48 | { 49 | "TraceID": "6", 50 | "SpanID": "4", 51 | "OperationName": "get todo", 52 | "StartTime": "2001-02-03T06:05:16Z", 53 | "DurationMilliSec": 10000, 54 | "Tags": { 55 | "foo": "bar" 56 | }, 57 | "ServiceName": "todo", 58 | "SpanRef": { 59 | "TraceID": "6", 60 | "SpanID": "1" 61 | } 62 | }, 63 | { 64 | "TraceID": "6", 65 | "SpanID": "5", 66 | "OperationName": "get todo", 67 | "StartTime": "2001-02-03T06:05:16Z", 68 | "DurationMilliSec": 10000, 69 | "Tags": { 70 | "db.system": "mysql" 71 | }, 72 | "ServiceName": "todo", 73 | "SpanRef": { 74 | "TraceID": "6", 75 | "SpanID": "4" 76 | } 77 | } 78 | ] 79 | } 80 | -------------------------------------------------------------------------------- /docker/mysql8/init/script.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id int PRIMARY KEY NOT NULL AUTO_INCREMENT, 3 | name varchar(255) NOT NULL, 4 | email varchar(255) NOT NULL, 5 | password varchar(255) NOT NULL, 6 | created_at datetime DEFAULT NULL, 7 | updated_at datetime DEFAULT NULL 8 | ); 9 | 10 | ALTER TABLE users ADD INDEX (id, email); 11 | 12 | CREATE TABLE tasks ( 13 | id int PRIMARY KEY NOT NULL AUTO_INCREMENT, 14 | user_id int NOT NULL, 15 | title varchar(50) NOT NULL, 16 | description varchar(100) DEFAULT NULL, 17 | status tinyint NOT NULL, 18 | created_at datetime DEFAULT NULL, 19 | updated_at datetime DEFAULT NULL 20 | ); 21 | 22 | /* Insert 256*256(=65,536) rows */ 23 | INSERT users(name, email, password, created_at, updated_at) 24 | WITH l0 AS (SELECT 0 AS num UNION ALL SELECT 1), 25 | l1 AS (SELECT a.num * 2 + b.num AS num FROM l0 AS a CROSS JOIN l0 AS b), 26 | l2 AS (SELECT a.num * 4 + b.num AS num FROM l1 AS a CROSS JOIN l1 AS b), 27 | l3 AS (SELECT a.num * 16 + b.num AS num FROM l2 AS a CROSS JOIN l2 AS b), 28 | l4 AS (SELECT a.num * 256 + b.num AS num FROM l3 AS a CROSS JOIN l3 AS b) 29 | SELECT CONCAT('test', CAST(num AS char)), 30 | CONCAT('test', CAST(num AS char), '@example.com'), 31 | 'p@ssw0rd', 32 | NOW() - INTERVAL ((num%363) + 1) DAY, 33 | NOW() - INTERVAL (num%363) DAY 34 | FROM l4; 35 | 36 | /* Insert 65,536*100(=6,553,600) rows */ 37 | INSERT INTO tasks(user_id, title, description, status, created_at, updated_at) 38 | SELECT users.id, 39 | 'test title', 40 | 'test description', 41 | tmp_100.id % 3, 42 | users.updated_at - INTERVAL ((tmp_100.id%100) +5) HOUR, 43 | users.updated_at - INTERVAL (tmp_100.id%100) HOUR 44 | FROM users 45 | CROSS JOIN (SELECT * FROM users LIMIT 100) tmp_100; 46 | -------------------------------------------------------------------------------- /docker/node/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 2 | 3 | RUN npm install --location=global hasura-cli 4 | -------------------------------------------------------------------------------- /docker/node/hasura/config.yaml: -------------------------------------------------------------------------------- 1 | version: 3 2 | endpoint: http://hasura2:8080 3 | metadata_directory: metadata 4 | actions: 5 | kind: synchronous 6 | handler_webhook_baseurl: http://localhost:3000 7 | -------------------------------------------------------------------------------- /docker/node/hasura/metadata/actions.graphql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrasu/GravityR/334f444ab5e0c8912b22d2bb7708484034551a2c/docker/node/hasura/metadata/actions.graphql -------------------------------------------------------------------------------- /docker/node/hasura/metadata/actions.yaml: -------------------------------------------------------------------------------- 1 | actions: [] 2 | custom_types: 3 | enums: [] 4 | input_objects: [] 5 | objects: [] 6 | scalars: [] 7 | -------------------------------------------------------------------------------- /docker/node/hasura/metadata/allow_list.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /docker/node/hasura/metadata/api_limits.yaml: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /docker/node/hasura/metadata/cron_triggers.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /docker/node/hasura/metadata/databases/databases.yaml: -------------------------------------------------------------------------------- 1 | - name: default 2 | kind: postgres 3 | configuration: 4 | connection_info: 5 | database_url: 6 | from_env: HASURA_GRAPHQL_DATABASE_URL 7 | isolation_level: read-committed 8 | pool_settings: 9 | connection_lifetime: 600 10 | idle_timeout: 180 11 | max_connections: 50 12 | retries: 1 13 | use_prepared_statements: true 14 | tables: "!include default/tables/tables.yaml" 15 | -------------------------------------------------------------------------------- /docker/node/hasura/metadata/databases/default/tables/public_tasks.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: tasks 3 | schema: public 4 | object_relationships: 5 | - name: user 6 | using: 7 | manual_configuration: 8 | column_mapping: 9 | user_id: id 10 | insertion_order: null 11 | remote_table: 12 | name: users 13 | schema: public 14 | -------------------------------------------------------------------------------- /docker/node/hasura/metadata/databases/default/tables/public_users.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: users 3 | schema: public 4 | array_relationships: 5 | - name: tasks 6 | using: 7 | manual_configuration: 8 | column_mapping: 9 | id: user_id 10 | insertion_order: null 11 | remote_table: 12 | name: tasks 13 | schema: public 14 | -------------------------------------------------------------------------------- /docker/node/hasura/metadata/databases/default/tables/tables.yaml: -------------------------------------------------------------------------------- 1 | - "!include public_tasks.yaml" 2 | - "!include public_users.yaml" 3 | -------------------------------------------------------------------------------- /docker/node/hasura/metadata/graphql_schema_introspection.yaml: -------------------------------------------------------------------------------- 1 | disabled_for_roles: [] 2 | -------------------------------------------------------------------------------- /docker/node/hasura/metadata/inherited_roles.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /docker/node/hasura/metadata/network.yaml: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /docker/node/hasura/metadata/query_collections.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /docker/node/hasura/metadata/remote_schemas.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /docker/node/hasura/metadata/rest_endpoints.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /docker/node/hasura/metadata/version.yaml: -------------------------------------------------------------------------------- 1 | version: 3 2 | -------------------------------------------------------------------------------- /docker/postgres14/init/script.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id SERIAL PRIMARY KEY, 3 | name varchar(255) NOT NULL, 4 | email varchar(255) NOT NULL, 5 | password varchar(255) NOT NULL, 6 | created_at timestamp with time zone DEFAULT NULL, 7 | updated_at timestamp with time zone DEFAULT NULL 8 | ); 9 | 10 | CREATE INDEX ON users(id, email); 11 | 12 | CREATE TABLE tasks ( 13 | id SERIAL PRIMARY KEY, 14 | user_id int NOT NULL, 15 | title varchar(50) NOT NULL, 16 | description varchar(100) DEFAULT NULL, 17 | status smallint NOT NULL, 18 | created_at timestamp with time zone DEFAULT NULL, 19 | updated_at timestamp with time zone DEFAULT NULL 20 | ); 21 | 22 | /* Insert 256*256(=65,536) rows */ 23 | WITH l0 AS (SELECT 0 AS num UNION ALL SELECT 1), 24 | l1 AS (SELECT a.num * 2 + b.num AS num FROM l0 AS a CROSS JOIN l0 AS b), 25 | l2 AS (SELECT a.num * 4 + b.num AS num FROM l1 AS a CROSS JOIN l1 AS b), 26 | l3 AS (SELECT a.num * 16 + b.num AS num FROM l2 AS a CROSS JOIN l2 AS b), 27 | l4 AS (SELECT a.num * 256 + b.num AS num FROM l3 AS a CROSS JOIN l3 AS b) 28 | INSERT INTO users(name, email, password, created_at, updated_at) 29 | SELECT CONCAT('test', num), 30 | CONCAT('test', num, '@example.com'), 31 | 'p@ssw0rd', 32 | CURRENT_TIMESTAMP - INTERVAL '1 DAY' * ((num%363) + 1), 33 | CURRENT_TIMESTAMP - INTERVAL '1 DAY' * (num%363) 34 | FROM l4; 35 | 36 | /* Insert 65,536*100(=6,553,600) rows */ 37 | INSERT INTO tasks(user_id, title, description, status, created_at, updated_at) 38 | SELECT users.id, 39 | 'test title', 40 | 'test description', 41 | tmp_100.id % 3, 42 | users.updated_at - INTERVAL '1 HOUR' * ((tmp_100.id%100) +5), 43 | users.updated_at - INTERVAL '1 HOUR' * (tmp_100.id%100) 44 | FROM users 45 | CROSS JOIN (SELECT * FROM users LIMIT 100) tmp_100; 46 | -------------------------------------------------------------------------------- /docs/images/jaeger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrasu/GravityR/334f444ab5e0c8912b22d2bb7708484034551a2c/docs/images/jaeger.png -------------------------------------------------------------------------------- /docs/images/postgres.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrasu/GravityR/334f444ab5e0c8912b22d2bb7708484034551a2c/docs/images/postgres.png -------------------------------------------------------------------------------- /html/html_builder.go: -------------------------------------------------------------------------------- 1 | package html 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/injection" 5 | "github.com/pkg/errors" 6 | "html/template" 7 | "io/fs" 8 | "os" 9 | ) 10 | 11 | const ( 12 | TypeMain = "main" 13 | TypeMermaid = "mermaid" 14 | ) 15 | 16 | func CreateHtml(htmlType, filename string, bo *BuildOption) error { 17 | script, err := fs.ReadFile(injection.ClientDist, "client/dist/assets/"+htmlType+".js") 18 | if err != nil { 19 | return errors.Wrap(err, "failed to open file") 20 | } 21 | style, err := fs.ReadFile(injection.ClientDist, "client/dist/assets/"+htmlType+".css") 22 | if err != nil { 23 | return errors.Wrap(err, "failed to open file") 24 | } 25 | 26 | tmpl := ` 27 | <!DOCTYPE html> 28 | <html lang="en"> 29 | <head> 30 | <meta charset="UTF-8" /> 31 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 32 | <title>GravityR</title> 33 | <script> 34 | window.grParam = {{.gr}} 35 | </script> 36 | <style>{{.style}}</style> 37 | </head> 38 | <body> 39 | <div id="app"></div> 40 | <script>{{.script}}</script> 41 | </body> 42 | </html> 43 | ` 44 | tpl, err := template.New("").Parse(tmpl) 45 | if err != nil { 46 | return errors.Wrap(err, "failed create template") 47 | } 48 | m := map[string]interface{}{ 49 | "script": template.JS(script), 50 | "style": template.CSS(style), 51 | "gr": bo.createGrMap(), 52 | } 53 | 54 | f, err := os.Create(filename) 55 | if err != nil { 56 | return errors.Wrap(err, "failed to create html file") 57 | } 58 | 59 | err = tpl.Execute(f, m) 60 | if err != nil { 61 | return errors.Wrap(err, "failed to write template to html") 62 | } 63 | 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /html/viewmodel/vm_db_load.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | type VmDbLoad struct { 4 | Name string `json:"name"` 5 | Sqls []*VmDbLoadOfSql `json:"sqls"` 6 | } 7 | 8 | func NewVmDbLoad(name string) *VmDbLoad { 9 | return &VmDbLoad{ 10 | Name: name, 11 | Sqls: []*VmDbLoadOfSql{}, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /html/viewmodel/vm_db_load_of_sql.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | type VmDbLoadOfSql struct { 4 | Sql string `json:"sql"` 5 | LoadMax float64 `json:"loadMax"` 6 | LoadSum float64 `json:"loadSum"` 7 | TokenizedId string `json:"tokenizedId"` 8 | } 9 | -------------------------------------------------------------------------------- /html/viewmodel/vm_examination_command_option.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | ) 7 | 8 | type VmExaminationCommandOption struct { 9 | IsShort bool `json:"isShort"` 10 | Name string `json:"name"` 11 | Value string `json:"value"` 12 | } 13 | 14 | func CreateOutputExaminationOption(addPrefix bool, filename string) *VmExaminationCommandOption { 15 | val := filename 16 | if addPrefix { 17 | val = strings.TrimSuffix(filename, filepath.Ext(filename)) + "_examine" + filepath.Ext(filename) 18 | } 19 | 20 | return &VmExaminationCommandOption{ 21 | IsShort: true, 22 | Name: "o", 23 | Value: val, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /html/viewmodel/vm_examination_index_result.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | type VmExaminationIndexResult struct { 4 | ExecutionTimeMillis int64 `json:"executionTimeMillis"` 5 | IndexTarget *VmIndexTarget `json:"indexTarget"` 6 | } 7 | -------------------------------------------------------------------------------- /html/viewmodel/vm_examination_result.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | type VmExaminationResult struct { 4 | OriginalTimeMillis int64 `json:"originalTimeMillis"` 5 | IndexResults []*VmExaminationIndexResult `json:"indexResults"` 6 | } 7 | -------------------------------------------------------------------------------- /html/viewmodel/vm_index_column.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | type VmIndexColumn struct { 4 | Name string `json:"name"` 5 | } 6 | -------------------------------------------------------------------------------- /html/viewmodel/vm_index_target.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | type VmIndexTarget struct { 4 | TableName string `json:"tableName"` 5 | Columns []*VmIndexColumn `json:"columns"` 6 | } 7 | -------------------------------------------------------------------------------- /html/viewmodel/vm_mysql_explain_analyze_node.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | import "gopkg.in/guregu/null.v4" 4 | 5 | type VmMysqlExplainAnalyzeNode struct { 6 | Text string `json:"text"` 7 | Title string `json:"title"` 8 | TableName string `json:"tableName"` 9 | EstimatedInitCost null.Float `json:"estimatedInitCost"` 10 | EstimatedCost null.Float `json:"estimatedCost"` 11 | EstimatedReturnedRows null.Int `json:"estimatedReturnedRows"` 12 | ActualTimeFirstRow null.Float `json:"actualTimeFirstRow"` 13 | ActualTimeAvg null.Float `json:"actualTimeAvg"` 14 | ActualReturnedRows null.Int `json:"actualReturnedRows"` 15 | ActualLoopCount null.Int `json:"actualLoopCount"` 16 | 17 | Children []*VmMysqlExplainAnalyzeNode `json:"children"` 18 | } 19 | -------------------------------------------------------------------------------- /html/viewmodel/vm_otel_compact_trace.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | type VmOtelCompactTrace struct { 4 | TraceId string `json:"traceId"` 5 | SameServiceAccessCount int `json:"sameServiceAccessCount"` 6 | TimeConsumingServiceName string `json:"timeConsumingServiceName"` 7 | Root *VmOtelTraceSpan `json:"root"` 8 | } 9 | -------------------------------------------------------------------------------- /html/viewmodel/vm_otel_trace_span.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | type VmOtelTraceSpan struct { 4 | Name string `json:"name"` 5 | SpanId string `json:"spanId"` 6 | StartTimeMillis int `json:"startTimeMillis"` 7 | EndTimeMillis int `json:"endTimeMillis"` 8 | ServiceName string `json:"serviceName"` 9 | Children []*VmOtelTraceSpan `json:"children"` 10 | } 11 | -------------------------------------------------------------------------------- /html/viewmodel/vm_postgres_explain_analyze_node.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | import "gopkg.in/guregu/null.v4" 4 | 5 | type VmPostgresExplainAnalyzeNode struct { 6 | Text string `json:"text"` 7 | Title string `json:"title"` 8 | TableName string `json:"tableName"` 9 | EstimatedInitCost null.Float `json:"estimatedInitCost"` 10 | EstimatedCost null.Float `json:"estimatedCost"` 11 | EstimatedReturnedRows null.Int `json:"estimatedReturnedRows"` 12 | EstimatedWidth null.Int `json:"estimatedWidth"` 13 | ActualTimeFirstRow null.Float `json:"actualTimeFirstRow"` 14 | ActualTimeAvg null.Float `json:"actualTimeAvg"` 15 | ActualReturnedRows null.Int `json:"actualReturnedRows"` 16 | ActualLoopCount null.Int `json:"actualLoopCount"` 17 | 18 | Children []*VmPostgresExplainAnalyzeNode `json:"children"` 19 | } 20 | -------------------------------------------------------------------------------- /html/viewmodel/vm_time_db_load.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | ) 7 | 8 | type VmTimestamp time.Time 9 | 10 | func (t VmTimestamp) MarshalJSON() ([]byte, error) { 11 | return []byte(strconv.FormatInt(time.Time(t).UnixMilli(), 10)), nil 12 | } 13 | 14 | type VmTimeDbLoad struct { 15 | Timestamp VmTimestamp `json:"timestamp"` 16 | Databases []*VmDbLoad `json:"databases"` 17 | } 18 | 19 | func NewVmTimeDbLoad(t time.Time) *VmTimeDbLoad { 20 | return &VmTimeDbLoad{ 21 | Timestamp: VmTimestamp(t), 22 | Databases: []*VmDbLoad{}, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /infra/aws/aws.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "github.com/aws/aws-sdk-go-v2/aws" 6 | "github.com/aws/aws-sdk-go-v2/config" 7 | "github.com/aws/smithy-go/logging" 8 | "github.com/pkg/errors" 9 | "os" 10 | ) 11 | 12 | const mockEndpoint = "http://localhost:8080" 13 | const verboseMode = aws.LogRetries | aws.LogRequest | aws.LogResponse 14 | 15 | func NewAwsConfig(useMock bool, verbose bool) (aws.Config, error) { 16 | if useMock { 17 | return newMockedConfig(verbose), nil 18 | } 19 | 20 | var optFns []func(*config.LoadOptions) error 21 | if verbose { 22 | optFns = append(optFns, config.WithClientLogMode(verboseMode)) 23 | } 24 | 25 | cfg, err := config.LoadDefaultConfig(context.Background(), optFns...) 26 | if err != nil { 27 | return cfg, errors.Wrap(err, "failed to load aws config") 28 | } 29 | 30 | return cfg, nil 31 | } 32 | 33 | func newMockedConfig(verbose bool) aws.Config { 34 | cfg := aws.Config{ 35 | Logger: logging.NewStandardLogger(os.Stderr), 36 | } 37 | 38 | cfg.EndpointResolverWithOptions = aws.EndpointResolverWithOptionsFunc( 39 | func(service, region string, options ...interface{}) (aws.Endpoint, error) { 40 | return aws.Endpoint{ 41 | URL: mockEndpoint, 42 | }, nil 43 | }, 44 | ) 45 | if verbose { 46 | cfg.ClientLogMode = verboseMode 47 | } 48 | 49 | return cfg 50 | } 51 | -------------------------------------------------------------------------------- /infra/aws/pi_sql_load_avg.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/html/viewmodel" 5 | "github.com/mrasu/GravityR/lib" 6 | "time" 7 | ) 8 | 9 | type PiSQLLoadAvg struct { 10 | DbName string 11 | SQL string 12 | TokenizedId string 13 | Values []*PiTimeValue 14 | } 15 | 16 | func NewPiSQLLoadAvg(dbName, sql, tokenizedId string) *PiSQLLoadAvg { 17 | return &PiSQLLoadAvg{ 18 | DbName: dbName, 19 | SQL: sql, 20 | TokenizedId: tokenizedId, 21 | } 22 | } 23 | 24 | func ConvertPiSQLLoadAvgsToVms(start, end time.Time, avgs []*PiSQLLoadAvg) []*viewmodel.VmTimeDbLoad { 25 | sqlLoadMap := map[time.Time]*viewmodel.VmTimeDbLoad{} 26 | dbLoadMap := map[time.Time]map[string]*viewmodel.VmDbLoad{} 27 | for _, avg := range avgs { 28 | for _, v := range avg.Values { 29 | baseTime := lib.NormalizeTimeByHour(v.Time) 30 | if _, ok := sqlLoadMap[baseTime]; !ok { 31 | sqlLoadMap[baseTime] = viewmodel.NewVmTimeDbLoad(baseTime) 32 | } 33 | if _, ok := dbLoadMap[baseTime]; !ok { 34 | dbLoadMap[baseTime] = map[string]*viewmodel.VmDbLoad{} 35 | } 36 | load := sqlLoadMap[baseTime] 37 | 38 | if _, ok := dbLoadMap[baseTime][avg.DbName]; !ok { 39 | dbLoad := viewmodel.NewVmDbLoad(avg.DbName) 40 | dbLoadMap[baseTime][avg.DbName] = dbLoad 41 | load.Databases = append(load.Databases, dbLoad) 42 | } 43 | dbLoad := dbLoadMap[baseTime][avg.DbName] 44 | 45 | found := false 46 | for _, loadOfSql := range dbLoad.Sqls { 47 | if loadOfSql.Sql == avg.SQL { 48 | if loadOfSql.LoadMax < v.Value { 49 | loadOfSql.LoadMax = v.Value 50 | } 51 | loadOfSql.LoadSum += v.Value 52 | 53 | found = true 54 | break 55 | } 56 | } 57 | 58 | if !found { 59 | dbLoad.Sqls = append(dbLoad.Sqls, &viewmodel.VmDbLoadOfSql{ 60 | Sql: avg.SQL, 61 | LoadMax: v.Value, 62 | LoadSum: v.Value, 63 | TokenizedId: avg.TokenizedId, 64 | }) 65 | } 66 | } 67 | } 68 | 69 | target := lib.NormalizeTimeByHour(start).UTC() 70 | var res []*viewmodel.VmTimeDbLoad 71 | for !target.After(end) { 72 | if l, ok := sqlLoadMap[target]; ok { 73 | res = append(res, l) 74 | } else { 75 | res = append(res, viewmodel.NewVmTimeDbLoad(target)) 76 | } 77 | target = target.Add(1 * time.Hour) 78 | } 79 | 80 | return res 81 | } 82 | -------------------------------------------------------------------------------- /infra/aws/pi_time_value.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import "time" 4 | 5 | type PiTimeValue struct { 6 | Time time.Time 7 | Value float64 8 | } 9 | -------------------------------------------------------------------------------- /infra/aws/rds.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "github.com/aws/aws-sdk-go-v2/aws" 6 | aRds "github.com/aws/aws-sdk-go-v2/service/rds" 7 | "github.com/aws/aws-sdk-go-v2/service/rds/types" 8 | "github.com/mrasu/GravityR/lib" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | type Rds struct { 13 | client aRds.DescribeDBInstancesAPIClient 14 | } 15 | 16 | func NewRds(cfg aws.Config) *Rds { 17 | cli := aRds.NewFromConfig(cfg) 18 | return NewRdsWithCli(cli) 19 | } 20 | 21 | func NewRdsWithCli(cli aRds.DescribeDBInstancesAPIClient) *Rds { 22 | return &Rds{ 23 | client: cli, 24 | } 25 | } 26 | 27 | func (rds *Rds) GetDBs(engines []string) ([]*RdsDB, error) { 28 | output, err := rds.client.DescribeDBInstances(context.Background(), &aRds.DescribeDBInstancesInput{ 29 | Filters: []types.Filter{ 30 | {Name: lib.Ptr("engine"), Values: engines}, 31 | }, 32 | }) 33 | if err != nil { 34 | return nil, errors.Wrap(err, "failed to describe db instances") 35 | } 36 | 37 | var res []*RdsDB 38 | for _, ins := range output.DBInstances { 39 | res = append(res, &RdsDB{ 40 | InstanceIdentifier: *ins.DBInstanceIdentifier, 41 | DbiResourceId: *ins.DbiResourceId, 42 | }) 43 | } 44 | 45 | return res, nil 46 | } 47 | -------------------------------------------------------------------------------- /infra/aws/rds_db.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | type RdsDB struct { 4 | InstanceIdentifier string 5 | DbiResourceId string 6 | } 7 | -------------------------------------------------------------------------------- /infra/aws/rds_test.go: -------------------------------------------------------------------------------- 1 | package aws_test 2 | 3 | import ( 4 | aAws "github.com/aws/aws-sdk-go-v2/aws" 5 | "github.com/jarcoal/httpmock" 6 | "github.com/mrasu/GravityR/infra/aws" 7 | "github.com/mrasu/GravityR/thelper" 8 | "github.com/stretchr/testify/assert" 9 | "net/http" 10 | "net/url" 11 | "testing" 12 | ) 13 | 14 | func TestRds_GetDBs(t *testing.T) { 15 | httpmock.Activate() 16 | defer httpmock.DeactivateAndReset() 17 | 18 | expDBs := []*aws.RdsDB{ 19 | { 20 | InstanceIdentifier: "gravityr1", 21 | DbiResourceId: "db-XXXXXXXXXXXXXXXXXXXXXXXXX1", 22 | }, 23 | { 24 | InstanceIdentifier: "gravityr2", 25 | DbiResourceId: "db-XXXXXXXXXXXXXXXXXXXXXXXXX2", 26 | }, 27 | } 28 | expReqBody := map[string][]string{ 29 | "Action": {"DescribeDBInstances"}, 30 | "Filters.Filter.1.Name": {"engine"}, 31 | "Filters.Filter.1.Values.Value.1": {"mysql"}, 32 | "Version": {"2014-10-31"}, 33 | } 34 | 35 | reqBody := map[string][]string{} 36 | httpmock.RegisterResponder("POST", "https://rds.ap-northeast-1.amazonaws.com/", 37 | func(req *http.Request) (*http.Response, error) { 38 | reqBody = unmarshalRdsBody(t, req) 39 | 40 | resp := thelper.ReadFromFiles(t, "aws/rds/DescribeDbInstances.xml") 41 | return httpmock.NewStringResponse(200, resp), nil 42 | }, 43 | ) 44 | 45 | rds := buildRds(t) 46 | dbs, err := rds.GetDBs([]string{"mysql"}) 47 | assert.NoError(t, err) 48 | 49 | assert.Equal(t, 1, httpmock.GetTotalCallCount()) 50 | assert.Equal(t, expReqBody, reqBody) 51 | assert.Equal(t, expDBs, dbs) 52 | } 53 | 54 | func buildRds(t *testing.T) *aws.Rds { 55 | t.Helper() 56 | 57 | cfg := aAws.Config{ 58 | Region: "ap-northeast-1", 59 | } 60 | cfg.HTTPClient = http.DefaultClient 61 | 62 | return aws.NewRds(cfg) 63 | } 64 | 65 | func unmarshalRdsBody(t *testing.T, req *http.Request) url.Values { 66 | t.Helper() 67 | 68 | err := req.ParseForm() 69 | assert.NoError(t, err) 70 | 71 | return req.Form 72 | } 73 | -------------------------------------------------------------------------------- /infra/hasura/column_info.go: -------------------------------------------------------------------------------- 1 | package hasura 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mrasu/GravityR/lib" 6 | "github.com/pkg/errors" 7 | "regexp" 8 | "strings" 9 | ) 10 | 11 | const columnFetchQuery = ` 12 | SELECT 13 | pg_class.relname AS table_name, 14 | pg_attribute.attname AS column_name, 15 | COALESCE(pg_index.indisprimary, FALSE) AS is_pk 16 | FROM 17 | pg_class 18 | INNER JOIN pg_attribute ON pg_attribute.attrelid = pg_class.oid 19 | INNER JOIN pg_namespace ON pg_class.relnamespace = pg_namespace.oid 20 | LEFT OUTER JOIN pg_index ON pg_class.oid = pg_index.indrelid AND pg_attribute.attnum = ANY(pg_index.indkey) 21 | WHERE 22 | pg_namespace.nspname = ? AND 23 | pg_class.relname IN (?) AND 24 | pg_class.relkind = 'r' AND 25 | pg_attribute.attnum >= 0 26 | ORDER BY 27 | pg_class.relname, 28 | pg_attribute.attnum 29 | ` 30 | 31 | type ColumnInfo struct { 32 | ColumnName string 33 | IsPK bool 34 | TableName string 35 | } 36 | 37 | var wordOnlyReg = regexp.MustCompile(`\A\w+\z`) 38 | 39 | func buildColumnFetchQuery(schema string, tableNames []string) (string, error) { 40 | if !wordOnlyReg.MatchString(schema) { 41 | return "", errors.Errorf("schema name can contains only a-z or _: %s", schema) 42 | } 43 | for _, t := range tableNames { 44 | if !wordOnlyReg.MatchString(t) { 45 | return "", errors.Errorf("table name can contains only a-z or _: %s", t) 46 | } 47 | } 48 | ts := lib.Join(tableNames, ",", func(v string) string { return fmt.Sprintf("'%s'", v) }) 49 | q := strings.Replace(columnFetchQuery, "?", fmt.Sprintf("'%s'", schema), 1) 50 | q = strings.Replace(q, "?", ts, 1) 51 | 52 | return q, nil 53 | } 54 | 55 | func parseColumnFetchResult(res *RunSQLResponse) []*ColumnInfo { 56 | var cols []*ColumnInfo 57 | for i, r := range res.Result { 58 | if i == 0 { 59 | // first row shows name of columns 60 | continue 61 | } 62 | 63 | isPk := false 64 | if r[2] == "t" { 65 | isPk = true 66 | } 67 | 68 | cols = append(cols, &ColumnInfo{ 69 | TableName: r[0], 70 | ColumnName: r[1], 71 | IsPK: isPk, 72 | }) 73 | } 74 | 75 | return cols 76 | } 77 | -------------------------------------------------------------------------------- /infra/hasura/config.go: -------------------------------------------------------------------------------- 1 | package hasura 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mrasu/GravityR/lib" 6 | "github.com/pkg/errors" 7 | "net/url" 8 | ) 9 | 10 | type Config struct { 11 | Url *url.URL 12 | AdminSecret string 13 | } 14 | 15 | func NewConfigFromEnv() (*Config, error) { 16 | u, err := lib.GetEnv("HASURA_URL") 17 | if err != nil { 18 | return nil, err 19 | } 20 | uu, err := url.Parse(u) 21 | if err != nil { 22 | return nil, errors.Wrap(err, fmt.Sprintf("cannot parse URL for Hasura: %s", u)) 23 | } 24 | 25 | s, err := lib.GetEnv("HASURA_ADMIN_SECRET") 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return &Config{ 31 | Url: uu, 32 | AdminSecret: s, 33 | }, nil 34 | } 35 | -------------------------------------------------------------------------------- /infra/hasura/explain_request_body.go: -------------------------------------------------------------------------------- 1 | package hasura 2 | 3 | type ExplainRequestBody struct { 4 | Query *Query `json:"query"` 5 | User map[string]string `json:"user"` 6 | } 7 | -------------------------------------------------------------------------------- /infra/hasura/explain_response.go: -------------------------------------------------------------------------------- 1 | package hasura 2 | 3 | type ExplainResponse struct { 4 | Field string `json:"field"` 5 | Plan []string `json:"plan"` 6 | SQL string `json:"sql"` 7 | } 8 | -------------------------------------------------------------------------------- /infra/hasura/index_info.go: -------------------------------------------------------------------------------- 1 | package hasura 2 | 3 | import ( 4 | "fmt" 5 | "github.com/lib/pq" 6 | "github.com/mrasu/GravityR/lib" 7 | "github.com/pkg/errors" 8 | "strings" 9 | ) 10 | 11 | const indexFetchQuery = ` 12 | SELECT 13 | t.relname AS table_name, 14 | ARRAY( 15 | SELECT PG_GET_INDEXDEF(pg_index.indexrelid, k + 1, TRUE) 16 | FROM GENERATE_SUBSCRIPTS(pg_index.indkey, 1) AS k 17 | ORDER BY k 18 | ) AS column_names 19 | FROM pg_index 20 | INNER JOIN pg_class AS i ON pg_index.indexrelid = i.oid 21 | INNER JOIN pg_class AS t ON pg_index.indrelid = t.oid 22 | INNER JOIN pg_namespace ON i.relnamespace = pg_namespace.oid 23 | WHERE 24 | pg_namespace.nspname = ? AND 25 | t.relname IN (?) 26 | ORDER BY 27 | t.relname, 28 | i.relname 29 | ` 30 | 31 | type IndexInfo struct { 32 | TableName string 33 | Columns []string 34 | } 35 | 36 | func buildIndexFetchQuery(schema string, tableNames []string) (string, error) { 37 | if !wordOnlyReg.MatchString(schema) { 38 | return "", errors.Errorf("schema name can contains only a-z or _: %s", schema) 39 | } 40 | for _, t := range tableNames { 41 | if !wordOnlyReg.MatchString(t) { 42 | return "", errors.Errorf("table name can contains only a-z or _: %s", t) 43 | } 44 | } 45 | ts := lib.Join(tableNames, ",", func(v string) string { return fmt.Sprintf("'%s'", v) }) 46 | q := strings.Replace(indexFetchQuery, "?", fmt.Sprintf("'%s'", schema), 1) 47 | q = strings.Replace(q, "?", ts, 1) 48 | 49 | return q, nil 50 | } 51 | 52 | func parseIndexFetchResult(res *RunSQLResponse) ([]*IndexInfo, error) { 53 | var infos []*IndexInfo 54 | for i, r := range res.Result { 55 | if i == 0 { 56 | // first row shows name of columns 57 | continue 58 | } 59 | 60 | cols := pq.StringArray{} 61 | err := cols.Scan(r[1]) 62 | if err != nil { 63 | return nil, errors.Wrap(err, "unexpected result from hasura to fetch indexes: not array of string for column names") 64 | } 65 | 66 | infos = append(infos, &IndexInfo{ 67 | TableName: r[0], 68 | Columns: cols, 69 | }) 70 | } 71 | 72 | return infos, nil 73 | } 74 | -------------------------------------------------------------------------------- /infra/hasura/query.go: -------------------------------------------------------------------------------- 1 | package hasura 2 | 3 | type Query struct { 4 | Query string `json:"query"` 5 | Variables map[string]interface{} `json:"variables"` 6 | } 7 | -------------------------------------------------------------------------------- /infra/hasura/run_sql_args.go: -------------------------------------------------------------------------------- 1 | package hasura 2 | 3 | type RunSQLArgs struct { 4 | SQL string `json:"sql"` 5 | Source *string `json:"source"` 6 | Cascade *bool `json:"cascade"` 7 | CheckMetadataConsistency *bool `json:"check_metadata_consistency"` 8 | ReadOnly *bool `json:"read_only"` 9 | } 10 | -------------------------------------------------------------------------------- /infra/hasura/run_sql_response.go: -------------------------------------------------------------------------------- 1 | package hasura 2 | 3 | type RunSQLResponse struct { 4 | ResultType string `json:"result_type"` 5 | Result [][]string `json:"result"` 6 | } 7 | -------------------------------------------------------------------------------- /infra/jaeger/client.go: -------------------------------------------------------------------------------- 1 | package jaeger 2 | 3 | import ( 4 | "context" 5 | "github.com/jaegertracing/jaeger/proto-gen/api_v3" 6 | "github.com/pkg/errors" 7 | "google.golang.org/grpc" 8 | "google.golang.org/grpc/credentials/insecure" 9 | "io" 10 | "time" 11 | ) 12 | 13 | type Client struct { 14 | conn *grpc.ClientConn 15 | } 16 | 17 | func Open(address string, isSecure bool) (*Client, error) { 18 | opts := []grpc.DialOption{ 19 | grpc.WithBlock(), 20 | } 21 | if !isSecure { 22 | opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) 23 | } 24 | 25 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 26 | defer cancel() 27 | conn, err := grpc.DialContext(ctx, address, opts...) 28 | if err != nil { 29 | return nil, errors.Wrapf(err, "cannot connect to %s", address) 30 | } 31 | 32 | return NewClient(conn), nil 33 | } 34 | 35 | func NewClient(conn *grpc.ClientConn) *Client { 36 | return &Client{conn: conn} 37 | } 38 | 39 | func (c *Client) Close() error { 40 | err := c.conn.Close() 41 | if err != nil { 42 | return errors.Wrap(err, "failed to close grpc connection") 43 | } 44 | return nil 45 | } 46 | 47 | func (c *Client) FindTraces(param *api_v3.TraceQueryParameters) ([]*Trace, error) { 48 | client := api_v3.NewQueryServiceClient(c.conn) 49 | ctx := context.Background() 50 | req := &api_v3.FindTracesRequest{ 51 | Query: param, 52 | } 53 | resp, err := client.FindTraces(ctx, req) 54 | if err != nil { 55 | return nil, errors.Wrap(err, "failed to find traces") 56 | } 57 | 58 | var traces []*Trace 59 | for { 60 | chunk, err := resp.Recv() 61 | if err != nil { 62 | if errors.Is(err, io.EOF) { 63 | break 64 | } 65 | return nil, errors.Wrap(err, "failed to receive result to find traces") 66 | } 67 | spans := chunk.GetResourceSpans() 68 | 69 | traces = append(traces, &Trace{Spans: spans}) 70 | } 71 | 72 | return traces, nil 73 | } 74 | -------------------------------------------------------------------------------- /infra/jaeger/trace.go: -------------------------------------------------------------------------------- 1 | package jaeger 2 | 3 | import ( 4 | v1Trace "github.com/jaegertracing/jaeger/proto-gen/otel/trace/v1" 5 | ) 6 | 7 | type Trace struct { 8 | Spans []*v1Trace.ResourceSpans 9 | } 10 | -------------------------------------------------------------------------------- /infra/mysql/column_info.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | const columnFetchQuery = ` 4 | SELECT 5 | COLUMN_NAME, 6 | COLUMN_KEY, 7 | TABLE_NAME 8 | FROM 9 | information_schema.columns 10 | WHERE 11 | TABLE_SCHEMA = ? AND 12 | TABLE_NAME IN (?) 13 | ORDER BY TABLE_NAME, ORDINAL_POSITION 14 | ` 15 | 16 | type ColumnInfo struct { 17 | ColumnName string `db:"COLUMN_NAME"` 18 | ColumnKey string `db:"COLUMN_KEY"` 19 | TableName string `db:"TABLE_NAME"` 20 | } 21 | -------------------------------------------------------------------------------- /infra/mysql/config.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "github.com/go-sql-driver/mysql" 5 | "github.com/mrasu/GravityR/lib" 6 | "os" 7 | ) 8 | 9 | type Config struct { 10 | config *mysql.Config 11 | paramText string 12 | } 13 | 14 | func NewConfigFromEnv() (*Config, error) { 15 | cfg := mysql.NewConfig() 16 | username, err := lib.GetEnv("DB_USERNAME") 17 | if err != nil { 18 | return nil, err 19 | } 20 | cfg.User = username 21 | 22 | cfg.Passwd = os.Getenv("DB_PASSWORD") 23 | protocol := os.Getenv("DB_PROTOCOL") 24 | if len(protocol) == 0 { 25 | protocol = "tcp" 26 | } 27 | cfg.Net = protocol 28 | 29 | address := os.Getenv("DB_ADDRESS") 30 | if len(address) > 0 { 31 | cfg.Addr = address 32 | } 33 | 34 | database, err := lib.GetEnv("DB_DATABASE") 35 | if err != nil { 36 | return nil, err 37 | } 38 | cfg.DBName = database 39 | 40 | paramText := os.Getenv("DB_PARAM_TEXT") 41 | 42 | return &Config{ 43 | config: cfg, 44 | paramText: paramText, 45 | }, nil 46 | } 47 | 48 | func (cfg *Config) ToDSN() string { 49 | dsn := cfg.config.FormatDSN() 50 | if len(cfg.paramText) > 0 { 51 | dsn += "?" + cfg.paramText 52 | } 53 | 54 | return dsn 55 | } 56 | 57 | func (cfg *Config) GetDBName() string { 58 | return cfg.config.DBName 59 | } 60 | -------------------------------------------------------------------------------- /infra/mysql/index_info.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | const indexFetchQuery = ` 4 | SELECT 5 | TABLE_NAME, 6 | INDEX_NAME, 7 | COLUMN_NAME 8 | FROM 9 | information_schema.STATISTICS 10 | WHERE 11 | TABLE_SCHEMA = ? AND 12 | TABLE_NAME IN (?) 13 | ORDER BY 14 | TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX 15 | ` 16 | 17 | type IndexInfo struct { 18 | TableName string 19 | Columns []string 20 | } 21 | 22 | type flatIndexInfo struct { 23 | TableName string `db:"TABLE_NAME"` 24 | IndexName string `db:"INDEX_NAME"` 25 | ColumnName string `db:"COLUMN_NAME"` 26 | } 27 | 28 | func toIndexInfo(orderedFlatInfo []*flatIndexInfo) []*IndexInfo { 29 | var infos []*IndexInfo 30 | 31 | lastTName := "" 32 | lastIName := "" 33 | for _, flatInfo := range orderedFlatInfo { 34 | if lastTName == flatInfo.TableName && lastIName == flatInfo.IndexName { 35 | idx := infos[len(infos)-1] 36 | idx.Columns = append(idx.Columns, flatInfo.ColumnName) 37 | } else { 38 | infos = append(infos, &IndexInfo{ 39 | TableName: flatInfo.TableName, 40 | Columns: []string{flatInfo.ColumnName}, 41 | }) 42 | } 43 | 44 | lastTName = flatInfo.TableName 45 | lastIName = flatInfo.IndexName 46 | } 47 | 48 | return infos 49 | } 50 | -------------------------------------------------------------------------------- /infra/postgres/column_info.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | const columnFetchQuery = ` 4 | SELECT 5 | pg_class.relname AS table_name, 6 | pg_attribute.attname AS column_name, 7 | COALESCE(pg_index.indisprimary, FALSE) AS is_pk 8 | FROM 9 | pg_class 10 | INNER JOIN pg_attribute ON pg_attribute.attrelid = pg_class.oid 11 | INNER JOIN pg_namespace ON pg_class.relnamespace = pg_namespace.oid 12 | LEFT OUTER JOIN pg_index ON pg_class.oid = pg_index.indrelid AND pg_attribute.attnum = ANY(pg_index.indkey) 13 | WHERE 14 | pg_namespace.nspname = ? AND 15 | pg_class.relname IN (?) AND 16 | pg_class.relkind = 'r' AND 17 | pg_attribute.attnum >= 0 18 | ORDER BY 19 | pg_class.relname, 20 | pg_attribute.attnum; 21 | ` 22 | 23 | type ColumnInfo struct { 24 | ColumnName string `db:"column_name"` 25 | IsPK bool `db:"is_pk"` 26 | TableName string `db:"table_name"` 27 | } 28 | -------------------------------------------------------------------------------- /infra/postgres/config.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/lib" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | type Config struct { 10 | user string 11 | password string 12 | host string 13 | port string 14 | DBName string 15 | searchPath string 16 | paramText string 17 | } 18 | 19 | func NewConfigFromEnv() (*Config, error) { 20 | cfg := &Config{} 21 | username, err := lib.GetEnv("DB_USERNAME") 22 | if err != nil { 23 | return nil, err 24 | } 25 | cfg.user = username 26 | 27 | cfg.password = os.Getenv("DB_PASSWORD") 28 | 29 | cfg.host = os.Getenv("DB_HOST") 30 | cfg.port = os.Getenv("DB_PORT") 31 | 32 | database, err := lib.GetEnv("DB_DATABASE") 33 | if err != nil { 34 | return nil, err 35 | } 36 | cfg.DBName = database 37 | 38 | cfg.searchPath = os.Getenv("DB_SEARCH_PATH") 39 | 40 | cfg.paramText = os.Getenv("DB_PARAM_TEXT") 41 | 42 | return cfg, nil 43 | } 44 | 45 | // GetKVConnectionString returns libpq's connection string 46 | // c.f) https://pkg.go.dev/github.com/lib/pq#hdr-Connection_String_Parameters 47 | func (cfg *Config) GetKVConnectionString() string { 48 | var texts []string 49 | if cfg.user != "" { 50 | texts = append(texts, "user='"+cfg.user+"'") 51 | } 52 | if cfg.password != "" { 53 | texts = append(texts, "password='"+cfg.password+"'") 54 | } 55 | if cfg.host != "" { 56 | texts = append(texts, "host='"+cfg.host+"'") 57 | } 58 | if cfg.port != "" { 59 | texts = append(texts, "port='"+cfg.port+"'") 60 | } 61 | if cfg.DBName != "" { 62 | texts = append(texts, "dbname='"+cfg.DBName+"'") 63 | } 64 | if cfg.paramText != "" { 65 | texts = append(texts, cfg.paramText) 66 | } 67 | texts = append(texts, "sslmode=disable") 68 | 69 | return strings.Join(texts, " ") 70 | } 71 | 72 | func (cfg *Config) GetSearchPathOrPublic() string { 73 | if cfg.searchPath == "" { 74 | return "public" 75 | } 76 | 77 | return cfg.searchPath 78 | } 79 | -------------------------------------------------------------------------------- /infra/postgres/db.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "github.com/jmoiron/sqlx" 7 | _ "github.com/lib/pq" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | type DB struct { 12 | db *sqlx.DB 13 | } 14 | 15 | func OpenPostgresDB(cfg *Config) (*DB, error) { 16 | db, err := sqlx.Open("postgres", cfg.GetKVConnectionString()) 17 | if err != nil { 18 | return nil, errors.Wrap(err, "failed to connect") 19 | } 20 | 21 | return NewDB(db), nil 22 | } 23 | 24 | func NewDB(db *sqlx.DB) *DB { 25 | return &DB{db: db} 26 | } 27 | 28 | func (db *DB) Close() error { 29 | return db.db.Close() 30 | } 31 | 32 | func (db *DB) Exec(query string) (sql.Result, error) { 33 | res, err := db.db.Exec(query) 34 | if err != nil { 35 | return nil, errors.Wrap(err, fmt.Sprintf("failed to execute query: %s", query)) 36 | } 37 | 38 | return res, nil 39 | } 40 | 41 | func (db *DB) ExplainWithAnalyze(query string) ([]string, error) { 42 | var res []string 43 | err := db.db.Select(&res, "EXPLAIN (ANALYZE, BUFFERS) "+query) 44 | if err != nil { 45 | return nil, errors.Wrap(err, "failed to select") 46 | } 47 | 48 | return res, nil 49 | } 50 | 51 | func (db *DB) GetTableColumns(schema string, tables []string) ([]*ColumnInfo, error) { 52 | query, args, err := sqlx.In(columnFetchQuery, schema, tables) 53 | if err != nil { 54 | return nil, errors.Wrap(err, "failed to build query to get table schema") 55 | } 56 | query = db.db.Rebind(query) 57 | 58 | var cols []*ColumnInfo 59 | err = db.db.Select(&cols, query, args...) 60 | if err != nil { 61 | return nil, errors.Wrap(err, "failed to execute query to get table column") 62 | } 63 | 64 | return cols, nil 65 | } 66 | 67 | func (db *DB) GetIndexes(database string, tables []string) ([]*IndexInfo, error) { 68 | query, args, err := sqlx.In(indexFetchQuery, database, tables) 69 | if err != nil { 70 | return nil, errors.Wrap(err, "failed to build query to get table schema") 71 | } 72 | query = db.db.Rebind(query) 73 | 74 | var qInfos []*queryIndexInfo 75 | err = db.db.Select(&qInfos, query, args...) 76 | if err != nil { 77 | return nil, errors.Wrap(err, "failed to execute query to get indexes") 78 | } 79 | 80 | return toIndexInfo(qInfos), nil 81 | } 82 | -------------------------------------------------------------------------------- /infra/postgres/index_info.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "github.com/lib/pq" 5 | "github.com/samber/lo" 6 | ) 7 | 8 | const indexFetchQuery = ` 9 | SELECT 10 | i.relname AS index_name, 11 | t.relname AS table_name, 12 | ARRAY( 13 | SELECT PG_GET_INDEXDEF(pg_index.indexrelid, k + 1, TRUE) 14 | FROM GENERATE_SUBSCRIPTS(pg_index.indkey, 1) AS k 15 | ORDER BY k 16 | ) AS column_names 17 | FROM pg_index 18 | INNER JOIN pg_class AS i ON pg_index.indexrelid = i.oid 19 | INNER JOIN pg_class AS t ON pg_index.indrelid = t.oid 20 | INNER JOIN pg_namespace ON i.relnamespace = pg_namespace.oid 21 | WHERE 22 | pg_namespace.nspname = ? AND 23 | t.relname IN (?) 24 | ORDER BY 25 | t.relname, 26 | i.relname 27 | ` 28 | 29 | type IndexInfo struct { 30 | TableName string 31 | Columns []string 32 | } 33 | 34 | type queryIndexInfo struct { 35 | IndexName string `db:"index_name"` 36 | TableName string `db:"table_name"` 37 | ColumnNames pq.StringArray `db:"column_names"` 38 | } 39 | 40 | func toIndexInfo(queryInfos []*queryIndexInfo) []*IndexInfo { 41 | return lo.Map(queryInfos, func(qi *queryIndexInfo, _ int) *IndexInfo { 42 | return &IndexInfo{ 43 | TableName: qi.TableName, 44 | Columns: qi.ColumnNames, 45 | } 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /infra/rdb/db.go: -------------------------------------------------------------------------------- 1 | package rdb 2 | 3 | import "database/sql" 4 | 5 | type DB interface { 6 | Exec(query string) (sql.Result, error) 7 | } 8 | -------------------------------------------------------------------------------- /injection/bin_info.go: -------------------------------------------------------------------------------- 1 | package injection 2 | 3 | var BinInfo = binInfo{} 4 | 5 | type binInfo struct { 6 | Version string 7 | Commit string 8 | } 9 | 10 | func SetBinInfo(version, commit string) { 11 | BinInfo.Version = version 12 | BinInfo.Commit = commit 13 | } 14 | -------------------------------------------------------------------------------- /injection/client_dist.go: -------------------------------------------------------------------------------- 1 | package injection 2 | 3 | import ( 4 | "io/fs" 5 | ) 6 | 7 | var ClientDist fs.FS 8 | -------------------------------------------------------------------------------- /lab/micro/.gitignore: -------------------------------------------------------------------------------- 1 | bff/node_modules 2 | 3 | docker/*/data 4 | -------------------------------------------------------------------------------- /lab/micro/bff/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | 3 | WORKDIR /app 4 | 5 | CMD ["yarn", "nodemon", "server.js"] 6 | -------------------------------------------------------------------------------- /lab/micro/bff/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@opentelemetry/api": "^1.2.0", 4 | "@opentelemetry/core": "^1.7.0", 5 | "@opentelemetry/exporter-jaeger": "^1.7.0", 6 | "@opentelemetry/instrumentation": "^0.33.0", 7 | "@opentelemetry/instrumentation-express": "^0.31.3", 8 | "@opentelemetry/instrumentation-graphql": "^0.32.0", 9 | "@opentelemetry/instrumentation-http": "^0.33.0", 10 | "@opentelemetry/resources": "^1.7.0", 11 | "@opentelemetry/sdk-trace-base": "^1.7.0", 12 | "@opentelemetry/sdk-trace-node": "^1.7.0", 13 | "express": "^4.18.2", 14 | "express-graphql": "^0.12.0", 15 | "graphql": "^16.6.0", 16 | "nodemon": "^2.0.20" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lab/micro/bff/server.js: -------------------------------------------------------------------------------- 1 | require("./tel.js"); 2 | const express = require("express"); 3 | const { graphqlHTTP } = require("express-graphql"); 4 | const { buildSchema } = require("graphql"); 5 | const opentelemetry = require("@opentelemetry/api"); 6 | 7 | const schema = buildSchema(` 8 | type Query { 9 | users(ids: [Int!]!): [User!]! 10 | } 11 | 12 | type User { 13 | id: Int! 14 | name: String! 15 | email: String! 16 | histories: [PaymentHistory!] 17 | } 18 | 19 | type PaymentHistory { 20 | amount: Int! 21 | last_numbers: String! 22 | } 23 | `); 24 | 25 | const getTraceId = () => { 26 | const span = opentelemetry.trace.getActiveSpan(); 27 | const ctx = span.spanContext(); 28 | 29 | return `00-${ctx.traceId}-${ctx.spanId}-00`; 30 | }; 31 | 32 | const handleUsers = async (args) => { 33 | const ids = args.ids; 34 | const traceId = getTraceId(); 35 | const promises = ids.map((id) => 36 | (async function (id) { 37 | return fetch(`http://user:9002/users/${id}`, { 38 | headers: { 39 | // Couldn't find a way to inject traceparent header automatically 40 | traceparent: traceId, 41 | }, 42 | }) 43 | .then((r) => r.json()) 44 | .then((v) => ({ 45 | id: v.id, 46 | name: v.name, 47 | email: v.email, 48 | histories: v.histories.map((h) => ({ 49 | amount: h.amount, 50 | last_numbers: h.last_numbers, 51 | })), 52 | })); 53 | })(id) 54 | ); 55 | return await Promise.all(promises); 56 | }; 57 | 58 | const root = { 59 | users: handleUsers, 60 | }; 61 | 62 | const app = express(); 63 | app.use( 64 | "/graphql", 65 | graphqlHTTP({ 66 | schema: schema, 67 | rootValue: root, 68 | }) 69 | ); 70 | app.listen(9001); 71 | console.log("Running a BFF service at http://localhost:9001/graphql"); 72 | -------------------------------------------------------------------------------- /lab/micro/bff/tel.js: -------------------------------------------------------------------------------- 1 | const { Resource } = require('@opentelemetry/resources'); 2 | const { HttpInstrumentation } = require ('@opentelemetry/instrumentation-http'); 3 | const { SimpleSpanProcessor } = require ("@opentelemetry/sdk-trace-base"); 4 | const { NodeTracerProvider } = require("@opentelemetry/sdk-trace-node"); 5 | const { registerInstrumentations } = require('@opentelemetry/instrumentation'); 6 | const { ExpressInstrumentation } = require ('@opentelemetry/instrumentation-express'); 7 | const { GraphQLInstrumentation } = require("@opentelemetry/instrumentation-graphql"); 8 | const { JaegerExporter } = require("@opentelemetry/exporter-jaeger"); 9 | const { W3CTraceContextPropagator } = require("@opentelemetry/core"); 10 | const opentelemetry = require("@opentelemetry/api"); 11 | 12 | registerInstrumentations({ 13 | instrumentations: [ 14 | new HttpInstrumentation(), 15 | new ExpressInstrumentation(), 16 | new GraphQLInstrumentation() 17 | ] 18 | }) 19 | 20 | const provider = new NodeTracerProvider({ 21 | resource: Resource.default().merge(new Resource({ 22 | "service.name": "bff" 23 | })) 24 | }) 25 | 26 | const jaegerExporter = new JaegerExporter({ 27 | endpoint: "http://jaeger:14268/api/traces" 28 | }) 29 | provider.addSpanProcessor( 30 | new SimpleSpanProcessor(jaegerExporter) 31 | ); 32 | 33 | opentelemetry.propagation.setGlobalPropagator( new W3CTraceContextPropagator()) 34 | provider.register() 35 | -------------------------------------------------------------------------------- /lab/micro/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | bff: 5 | build: ./bff 6 | volumes: 7 | - ./bff:/app 8 | ports: 9 | - "9001:9001" 10 | user: 11 | build: ./user 12 | volumes: 13 | - ./user:/app 14 | ports: 15 | - "9002:9002" 16 | restart: unless-stopped 17 | user_db_mysql: 18 | image: mysql:8.0.23 19 | volumes: 20 | - ./docker/user_db_mysql/data:/var/lib/mysql 21 | - ./docker/user_db_mysql/init:/docker-entrypoint-initdb.d 22 | ports: 23 | - "9006:3306" 24 | environment: 25 | MYSQL_DATABASE: user_db 26 | MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' 27 | jaeger: 28 | image: jaegertracing/all-in-one:1.38 29 | ports: 30 | - "6831:6831/udp" 31 | - "6832:6832/udp" 32 | - "5778:5778" 33 | - "4317:4317" 34 | - "4318:4318" 35 | - "14250:14250" 36 | - "14268:14268" 37 | - "14269:14269" 38 | - "9411:9411" 39 | - "16685:16685" 40 | - "16686:16686" 41 | - "16687:16687" 42 | environment: 43 | COLLECTOR_ZIPKIN_HOST_PORT: ":9411" 44 | COLLECTOR_OTLP_ENABLED: true 45 | -------------------------------------------------------------------------------- /lab/micro/docker/user_db_mysql/init/script.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id int PRIMARY KEY NOT NULL AUTO_INCREMENT, 3 | name varchar(255) NOT NULL, 4 | email varchar(255) NOT NULL, 5 | password varchar(255) NOT NULL 6 | ); 7 | 8 | CREATE TABLE payment_methods ( 9 | id int PRIMARY KEY NOT NULL AUTO_INCREMENT, 10 | user_id int NOT NULL, 11 | last_numbers varchar(10) NOT NULL 12 | ); 13 | 14 | CREATE TABLE payment_histories ( 15 | id int PRIMARY KEY NOT NULL AUTO_INCREMENT, 16 | user_id int NOT NULL, 17 | payment_method_id int NOT NULL, 18 | amount int NOT NULL, 19 | payed_at datetime NOT NULL 20 | ); 21 | 22 | /* Insert 16 rows */ 23 | INSERT INTO users(id, name, email, password) 24 | WITH l0 AS (SELECT 0 AS num UNION ALL SELECT 1), 25 | l1 AS (SELECT a.num * 2 + b.num AS num FROM l0 AS a CROSS JOIN l0 AS b), 26 | l2 AS (SELECT a.num * 4 + b.num AS num FROM l1 AS a CROSS JOIN l1 AS b) 27 | SELECT num+1, 28 | CONCAT('test', CAST(num AS char)), 29 | CONCAT('test', CAST(num AS char), '@example.com'), 30 | 'p@ssw0rd' 31 | FROM l2; 32 | 33 | INSERT INTO payment_methods(id, user_id, last_numbers) 34 | SELECT 35 | id, 36 | id, 37 | id%10*1111 38 | FROM users; 39 | 40 | /* Insert 16*16 rows */ 41 | INSERT INTO payment_histories(id, user_id, payment_method_id, amount, payed_at) 42 | WITH l0 AS (SELECT 0 AS num UNION ALL SELECT 1), 43 | l1 AS (SELECT a.num * 2 + b.num AS num FROM l0 AS a CROSS JOIN l0 AS b), 44 | l2 AS (SELECT a.num * 4 + b.num AS num FROM l1 AS a CROSS JOIN l1 AS b) 45 | SELECT us.id*16+num, 46 | us.id, 47 | us.id, 48 | (us.id*16+num) * 100, 49 | NOW() - INTERVAL (num%16) MONTH 50 | FROM l2 CROSS JOIN (SELECT id FROM users) AS us; 51 | -------------------------------------------------------------------------------- /lab/micro/user/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod . 6 | COPY go.sum . 7 | 8 | RUN go mod download 9 | 10 | CMD ["go", "run", "."] 11 | -------------------------------------------------------------------------------- /lab/micro/user/db.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "gorm.io/driver/mysql" 6 | "gorm.io/gorm" 7 | "gorm.io/plugin/opentelemetry/tracing" 8 | "time" 9 | ) 10 | 11 | var globalDB *gorm.DB 12 | 13 | func initDB(dsn string) error { 14 | var err error 15 | globalDB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{}) 16 | if err != nil { 17 | return err 18 | } 19 | if err := globalDB.Use(tracing.NewPlugin(tracing.WithoutMetrics())); err != nil { 20 | return err 21 | } 22 | 23 | return nil 24 | } 25 | 26 | func dbWithCtx(ctx context.Context) *gorm.DB { 27 | return globalDB.WithContext(ctx) 28 | } 29 | 30 | type User struct { 31 | ID int32 `json:"id"` 32 | Name string `json:"name"` 33 | Email string `json:"email"` 34 | } 35 | 36 | type PaymentHistory struct { 37 | ID int32 38 | UserID int32 39 | PaymentMethodID int32 40 | Amount int32 41 | PayedAt time.Time 42 | } 43 | 44 | type PaymentMethod struct { 45 | ID int32 46 | UserID int32 47 | LastNumbers string 48 | } 49 | -------------------------------------------------------------------------------- /lab/micro/user/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mrasu/GravityR/jaeger/user 2 | 3 | go 1.19 4 | 5 | require ( 6 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.36.4 7 | go.opentelemetry.io/otel v1.11.1 8 | go.opentelemetry.io/otel/exporters/jaeger v1.11.1 9 | go.opentelemetry.io/otel/sdk v1.11.1 10 | go.opentelemetry.io/otel/trace v1.11.1 11 | gorm.io/driver/mysql v1.4.3 12 | gorm.io/gorm v1.24.0 13 | gorm.io/plugin/opentelemetry v0.1.0 14 | ) 15 | 16 | require ( 17 | github.com/felixge/httpsnoop v1.0.3 // indirect 18 | github.com/go-logr/logr v1.2.3 // indirect 19 | github.com/go-logr/stdr v1.2.2 // indirect 20 | github.com/go-sql-driver/mysql v1.6.0 // indirect 21 | github.com/jinzhu/inflection v1.0.0 // indirect 22 | github.com/jinzhu/now v1.1.5 // indirect 23 | go.opentelemetry.io/otel/metric v0.33.0 // indirect 24 | golang.org/x/sys v0.1.0 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /lab/micro/user/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 7 | "go.opentelemetry.io/otel/attribute" 8 | "go.opentelemetry.io/otel/trace" 9 | "net/http" 10 | "path/filepath" 11 | "strconv" 12 | "strings" 13 | ) 14 | 15 | const ( 16 | dbDSN = "root:@tcp(user_db_mysql:3306)/user_db?charset=utf8mb4&parseTime=True&loc=Local" 17 | ) 18 | 19 | func main() { 20 | err := initDB(dbDSN) 21 | if err != nil { 22 | panic(err) 23 | } 24 | 25 | err = initTraceProvider("http://jaeger:14268/api/traces") 26 | if err != nil { 27 | panic(err) 28 | } 29 | 30 | otelHandler := otelhttp.NewHandler(http.HandlerFunc(usersHandler), "users") 31 | http.Handle("/users/", otelHandler) 32 | fmt.Println("Running a user service at http://localhost:9002") 33 | http.ListenAndServe(":9002", nil) 34 | } 35 | 36 | func usersHandler(w http.ResponseWriter, r *http.Request) { 37 | db := dbWithCtx(r.Context()) 38 | fmt.Println("=========") 39 | fmt.Println(r.Header) 40 | fmt.Println(trace.SpanContextFromContext(r.Context()).TraceID().String()) 41 | 42 | sub := strings.TrimPrefix(r.URL.Path, "/users") 43 | _, file := filepath.Split(sub) 44 | id, err := strconv.Atoi(file) 45 | if err != nil { 46 | panic(err) 47 | } 48 | span := trace.SpanFromContext(r.Context()) 49 | span.SetAttributes(attribute.Key("user_id").Int(id)) 50 | fmt.Printf("getting user data for id=%d", id) 51 | 52 | var u *User 53 | if err := db.Where("id = ?", id).First(&u).Error; err != nil { 54 | panic(err) 55 | } 56 | var histories []*PaymentHistory 57 | if err := db.Where("user_id = ?", u.ID).Find(&histories).Error; err != nil { 58 | panic(err) 59 | } 60 | 61 | var resHistories []map[string]any 62 | for _, h := range histories { 63 | var m *PaymentMethod 64 | if err := db.Where("id = ?", h.PaymentMethodID).Find(&m).Error; err != nil { 65 | panic(err) 66 | } 67 | resHistories = append(resHistories, map[string]any{ 68 | "amount": h.Amount, 69 | "last_numbers": m.LastNumbers, 70 | }) 71 | } 72 | 73 | res := map[string]any{ 74 | "id": u.ID, 75 | "name": u.Name, 76 | "email": u.Email, 77 | "histories": resHistories, 78 | } 79 | out, err := json.Marshal(res) 80 | if err != nil { 81 | panic(err) 82 | } 83 | 84 | w.Write(out) 85 | } 86 | -------------------------------------------------------------------------------- /lab/micro/user/otel.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "go.opentelemetry.io/otel" 5 | "go.opentelemetry.io/otel/exporters/jaeger" 6 | "go.opentelemetry.io/otel/propagation" 7 | "go.opentelemetry.io/otel/sdk/resource" 8 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 9 | semconv "go.opentelemetry.io/otel/semconv/v1.12.0" 10 | ) 11 | 12 | const ( 13 | serviceName = "user" 14 | ) 15 | 16 | func initTraceProvider(url string) error { 17 | exp, err := jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(url))) 18 | if err != nil { 19 | return err 20 | } 21 | tp := sdktrace.NewTracerProvider( 22 | sdktrace.WithSampler(sdktrace.AlwaysSample()), 23 | sdktrace.WithBatcher(exp), 24 | sdktrace.WithResource(resource.NewWithAttributes( 25 | semconv.SchemaURL, 26 | semconv.ServiceNameKey.String(serviceName), 27 | )), 28 | ) 29 | otel.SetTracerProvider(tp) 30 | otel.SetTextMapPropagator(propagation.TraceContext{}) 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /lib/env.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "os" 6 | ) 7 | 8 | func GetEnv(key string) (string, error) { 9 | v := os.Getenv(key) 10 | if len(v) == 0 { 11 | return "", errors.Errorf("env: %s is not set", key) 12 | } 13 | return v, nil 14 | } 15 | -------------------------------------------------------------------------------- /lib/env_test.go: -------------------------------------------------------------------------------- 1 | package lib_test 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/lib" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | "os" 8 | "testing" 9 | ) 10 | 11 | func TestGetEnv(t *testing.T) { 12 | key := "test_dummy" 13 | os.Setenv(key, "dummy") 14 | 15 | v, err := lib.GetEnv(key) 16 | require.NoError(t, err) 17 | assert.Equal(t, "dummy", v) 18 | } 19 | 20 | func TestGetEnv_error(t *testing.T) { 21 | v, err := lib.GetEnv("invalid_key") 22 | assert.ErrorContains(t, err, "not set") 23 | assert.Equal(t, v, "") 24 | } 25 | -------------------------------------------------------------------------------- /lib/errors.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import "github.com/pkg/errors" 4 | 5 | type UnsupportedError struct { 6 | message string 7 | } 8 | 9 | func NewUnsupportedError(msg string) error { 10 | return errors.Wrap(&UnsupportedError{msg}, "") 11 | } 12 | 13 | func (ue *UnsupportedError) Error() string { 14 | return ue.message 15 | } 16 | 17 | type InvalidAstError struct { 18 | message string 19 | } 20 | 21 | func NewInvalidAstError(msg string) error { 22 | return errors.Wrap(&InvalidAstError{msg}, "") 23 | } 24 | 25 | func (ie *InvalidAstError) Error() string { 26 | return ie.message 27 | } 28 | -------------------------------------------------------------------------------- /lib/heap.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "container/heap" 5 | ) 6 | 7 | var _ heap.Interface = &anyHeap[any]{} 8 | 9 | type LimitedHeap[T any] struct { 10 | h *anyHeap[T] 11 | limit int 12 | } 13 | 14 | func NewLimitedHeap[T any](limit int, less func(a, b T) bool) *LimitedHeap[T] { 15 | h := &anyHeap[T]{less: less} 16 | heap.Init(h) 17 | return &LimitedHeap[T]{ 18 | h: h, 19 | limit: limit, 20 | } 21 | } 22 | 23 | func (h LimitedHeap[T]) Len() int { 24 | return h.h.Len() 25 | } 26 | 27 | func (h LimitedHeap[T]) Push(x T) { 28 | heap.Push(h.h, x) 29 | if h.h.Len() > h.limit { 30 | heap.Pop(h.h) 31 | } 32 | } 33 | 34 | func (h LimitedHeap[T]) PopAll() []T { 35 | var res []T 36 | for h.h.Len() > 0 { 37 | res = append(res, heap.Pop(h.h).(T)) 38 | } 39 | 40 | return res 41 | } 42 | 43 | type anyHeap[T any] struct { 44 | vals []T 45 | less func(a, b T) bool 46 | } 47 | 48 | func (h *anyHeap[T]) Len() int { 49 | return len(h.vals) 50 | } 51 | 52 | func (h *anyHeap[T]) Less(i, j int) bool { 53 | return h.less(h.vals[i], h.vals[j]) 54 | } 55 | 56 | func (h *anyHeap[T]) Swap(i, j int) { 57 | h.vals[i], h.vals[j] = h.vals[j], h.vals[i] 58 | } 59 | 60 | func (h *anyHeap[T]) Push(x any) { 61 | v := x.(T) 62 | h.vals = append(h.vals, v) 63 | } 64 | 65 | func (h *anyHeap[T]) Pop() any { 66 | l := len(h.vals) - 1 67 | v := h.vals[l] 68 | h.vals = h.vals[0:l] 69 | 70 | return v 71 | } 72 | -------------------------------------------------------------------------------- /lib/heap_test.go: -------------------------------------------------------------------------------- 1 | package lib_test 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/lib" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func TestLimitedHeap_Push(t *testing.T) { 10 | less := func(v1, v2 int) bool { 11 | return v1 < v2 12 | } 13 | tests := []struct { 14 | name string 15 | vals []int 16 | want []int 17 | }{ 18 | { 19 | name: "Keep when not reach the limit", 20 | vals: []int{1, 5, 3, 2, 4}, 21 | want: []int{1, 2, 3, 4, 5}, 22 | }, 23 | { 24 | name: "Omit the smallest element when exceeds the limit", 25 | vals: []int{1, 5, 3, 2, 4, 6}, 26 | want: []int{2, 3, 4, 5, 6}, 27 | }, 28 | } 29 | for _, tt := range tests { 30 | t.Run(tt.name, func(t *testing.T) { 31 | h := lib.NewLimitedHeap(5, less) 32 | for _, v := range tt.vals { 33 | h.Push(v) 34 | } 35 | assert.Equal(t, tt.want, h.PopAll()) 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/http_transport.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "github.com/rs/zerolog/log" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | type HttpTransport struct { 10 | Transport http.RoundTripper 11 | } 12 | 13 | func NewHttpTransport() *HttpTransport { 14 | return &HttpTransport{Transport: http.DefaultTransport} 15 | } 16 | 17 | func (ht *HttpTransport) RoundTrip(req *http.Request) (*http.Response, error) { 18 | log.Printf("[http] --> %s %s", req.Method, req.URL) 19 | startTime := time.Now() 20 | 21 | resp, err := ht.Transport.RoundTrip(req) 22 | 23 | duration := time.Since(startTime) 24 | duration /= time.Millisecond 25 | 26 | if err != nil { 27 | log.Printf("[http] <-- ERROR method=%s host=%s path=%s status=error durationMs=%d error=%q", req.Method, req.Host, req.URL.Path, duration, err.Error()) 28 | return nil, err 29 | } 30 | 31 | log.Printf("[http] <-- method=%s host=%s path=%s status=%d durationMs=%d", req.Method, req.Host, req.URL.Path, resp.StatusCode, duration) 32 | return resp, nil 33 | } 34 | -------------------------------------------------------------------------------- /lib/join.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import "strings" 4 | 5 | func Join[T any](vals []T, sep string, f func(T) string) string { 6 | var res []string 7 | for _, val := range vals { 8 | res = append(res, f(val)) 9 | } 10 | return strings.Join(res, sep) 11 | } 12 | -------------------------------------------------------------------------------- /lib/join_test.go: -------------------------------------------------------------------------------- 1 | package lib_test 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/lib" 5 | "github.com/stretchr/testify/assert" 6 | "strconv" 7 | "testing" 8 | ) 9 | 10 | func TestJoin(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | vals []int 14 | sep string 15 | expected string 16 | }{ 17 | { 18 | name: "Concat text from the result of function", 19 | vals: []int{1, 2, 3}, 20 | sep: "*", 21 | expected: "1*2*3", 22 | }, 23 | { 24 | name: "Empty when arg is empty", 25 | vals: []int{}, 26 | sep: "*", 27 | expected: "", 28 | }, 29 | } 30 | for _, tt := range tests { 31 | t.Run(tt.name, func(t *testing.T) { 32 | res := lib.Join(tt.vals, tt.sep, func(i int) string { return strconv.Itoa(i) }) 33 | assert.Equal(t, tt.expected, res) 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/ptr.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | func Ptr[T any](v T) *T { 4 | return &v 5 | } 6 | -------------------------------------------------------------------------------- /lib/set.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | type Set[T comparable] struct { 4 | vals map[T]bool 5 | } 6 | 7 | func NewSet[T comparable]() *Set[T] { 8 | return &Set[T]{map[T]bool{}} 9 | } 10 | 11 | func NewSetS[T comparable](origins []T) *Set[T] { 12 | vals := map[T]bool{} 13 | for _, o := range origins { 14 | vals[o] = true 15 | } 16 | return &Set[T]{vals} 17 | } 18 | 19 | func (s *Set[T]) Add(val T) { 20 | s.vals[val] = true 21 | } 22 | 23 | func (s *Set[T]) Merge(os *Set[T]) { 24 | for _, val := range os.Values() { 25 | s.Add(val) 26 | } 27 | } 28 | 29 | func (s *Set[T]) Delete(val T) { 30 | delete(s.vals, val) 31 | } 32 | 33 | func (s *Set[T]) Values() []T { 34 | var vals []T 35 | for val := range s.vals { 36 | vals = append(vals, val) 37 | } 38 | 39 | return vals 40 | } 41 | 42 | func (s *Set[T]) Contains(val T) bool { 43 | if _, ok := s.vals[val]; ok { 44 | return true 45 | } 46 | return false 47 | } 48 | 49 | func (s *Set[T]) Count() int { 50 | return len(s.vals) 51 | } 52 | -------------------------------------------------------------------------------- /lib/slice.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | func SliceOrEmpty[T any](vals []T) []T { 4 | if vals == nil { 5 | return []T{} 6 | } 7 | return vals 8 | } 9 | -------------------------------------------------------------------------------- /lib/slice_test.go: -------------------------------------------------------------------------------- 1 | package lib_test 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/lib" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func TestSliceOrEmpty(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | vals []int 13 | expected []int 14 | }{ 15 | { 16 | name: "Return as is when not empty", 17 | vals: []int{1, 2, 3}, 18 | expected: []int{1, 2, 3}, 19 | }, 20 | { 21 | name: "Return empty slice when empty", 22 | vals: []int{}, 23 | expected: []int{}, 24 | }, 25 | { 26 | name: "Return empty slice when nil", 27 | vals: nil, 28 | expected: []int{}, 29 | }, 30 | } 31 | 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | assert.Equal(t, tt.expected, lib.SliceOrEmpty(tt.vals)) 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/sort.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "golang.org/x/exp/constraints" 5 | "sort" 6 | ) 7 | 8 | func Sort[T any, U constraints.Ordered](vals []T, f func(v T) U) { 9 | sort.Slice(vals, func(i, j int) bool { 10 | vi := f(vals[i]) 11 | vj := f(vals[j]) 12 | 13 | return vi < vj 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /lib/sort_test.go: -------------------------------------------------------------------------------- 1 | package lib_test 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/lib" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func TestSort(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | vals []int 13 | expected []int 14 | }{ 15 | { 16 | name: "Sort values", 17 | vals: []int{3, 1, 2}, 18 | expected: []int{1, 2, 3}, 19 | }, 20 | { 21 | name: "As is when empty", 22 | vals: []int{}, 23 | expected: []int{}, 24 | }, 25 | } 26 | 27 | for _, tt := range tests { 28 | t.Run(tt.name, func(t *testing.T) { 29 | lib.Sort(tt.vals, func(v int) int { return v }) 30 | assert.Equal(t, tt.expected, tt.vals) 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/stack.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | type Stack[T any] struct { 4 | vals []*T 5 | } 6 | 7 | func NewStack[T any](vals ...*T) *Stack[T] { 8 | return &Stack[T]{vals: vals} 9 | } 10 | 11 | func (s *Stack[T]) Push(val *T) { 12 | s.vals = append(s.vals, val) 13 | } 14 | 15 | func (s *Stack[T]) Pop() *T { 16 | if len(s.vals) == 0 { 17 | return nil 18 | } 19 | 20 | v := s.vals[len(s.vals)-1] 21 | s.vals = s.vals[:len(s.vals)-1] 22 | 23 | return v 24 | } 25 | 26 | func (s *Stack[T]) Top() *T { 27 | if len(s.vals) == 0 { 28 | return nil 29 | } 30 | 31 | return s.vals[len(s.vals)-1] 32 | } 33 | 34 | func (s *Stack[T]) Size() int { 35 | return len(s.vals) 36 | } 37 | -------------------------------------------------------------------------------- /lib/stack_test.go: -------------------------------------------------------------------------------- 1 | package lib_test 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/lib" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | type data struct { 10 | val int 11 | } 12 | 13 | func TestStack_Push(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | vals []*data 17 | expectedValues []*data 18 | }{ 19 | { 20 | name: "Push once", 21 | vals: []*data{{1}}, 22 | expectedValues: []*data{{1}}, 23 | }, 24 | { 25 | name: "Push twice", 26 | vals: []*data{{1}, {2}}, 27 | expectedValues: []*data{{2}, {1}}, 28 | }, 29 | } 30 | 31 | for _, tt := range tests { 32 | t.Run(tt.name, func(t *testing.T) { 33 | s := lib.NewStack[data]() 34 | for _, v := range tt.vals { 35 | s.Push(v) 36 | } 37 | actualValues := popAllValues(s) 38 | assert.Equal(t, tt.expectedValues, actualValues) 39 | }) 40 | } 41 | } 42 | 43 | func TestStack_Top(t *testing.T) { 44 | tests := []struct { 45 | name string 46 | vals []*data 47 | expected *data 48 | }{ 49 | { 50 | name: "Return the last element", 51 | vals: []*data{{1}, {2}, {3}}, 52 | expected: &data{3}, 53 | }, 54 | { 55 | name: "Return nil when empty", 56 | vals: []*data{}, 57 | expected: nil, 58 | }, 59 | } 60 | 61 | for _, tt := range tests { 62 | t.Run(tt.name, func(t *testing.T) { 63 | s := lib.NewStack[data]() 64 | for _, v := range tt.vals { 65 | s.Push(v) 66 | } 67 | 68 | assert.Equal(t, tt.expected, s.Top()) 69 | }) 70 | } 71 | } 72 | 73 | func popAllValues[T any](s *lib.Stack[T]) []*T { 74 | var res []*T 75 | for { 76 | v := s.Pop() 77 | if v == nil { 78 | break 79 | } 80 | res = append(res, v) 81 | } 82 | 83 | return res 84 | } 85 | -------------------------------------------------------------------------------- /lib/time.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import "time" 4 | 5 | func NormalizeTimeByHour(t time.Time) time.Time { 6 | return time.Date( 7 | t.Year(), t.Month(), t.Day(), 8 | t.Hour(), 0, 0, 0, 9 | t.Location(), 10 | ) 11 | } 12 | 13 | func GenerateTimeRanges(start, end time.Time, interval time.Duration) []time.Time { 14 | var res []time.Time 15 | for t := start; t.Before(end); t = t.Add(interval) { 16 | res = append(res, t) 17 | } 18 | 19 | return res 20 | } 21 | -------------------------------------------------------------------------------- /lib/time_test.go: -------------------------------------------------------------------------------- 1 | package lib_test 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/lib" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestNormalizeTimeByHour(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | t time.Time 14 | expected time.Time 15 | }{ 16 | { 17 | name: "Make zeros under hour", 18 | t: time.Date(2001, 2, 3, 4, 5, 6, 7, time.UTC), 19 | expected: time.Date(2001, 2, 3, 4, 0, 0, 0, time.UTC), 20 | }, 21 | } 22 | 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | assert.Equal(t, tt.expected, lib.NormalizeTimeByHour(tt.t)) 26 | }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/uniq.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | func UniqBy[T any, U comparable](vals []T, fn func(T) U) []T { 4 | var res []T 5 | found := NewSet[U]() 6 | 7 | for _, v := range vals { 8 | u := fn(v) 9 | if found.Contains(u) { 10 | continue 11 | } 12 | res = append(res, v) 13 | found.Add(u) 14 | } 15 | 16 | return res 17 | } 18 | -------------------------------------------------------------------------------- /lib/uniq_test.go: -------------------------------------------------------------------------------- 1 | package lib_test 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/lib" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func TestUniqBy(t *testing.T) { 10 | type data struct{ val int } 11 | 12 | tests := []struct { 13 | name string 14 | vals []data 15 | fn func(data) int 16 | expected []data 17 | }{ 18 | { 19 | name: "Remove duplications", 20 | vals: []data{{1}, {2}, {3}, {2}, {1}}, 21 | fn: func(d data) int { return d.val }, 22 | expected: []data{{1}, {2}, {3}}, 23 | }, 24 | } 25 | 26 | for _, tt := range tests { 27 | t.Run(tt.name, func(t *testing.T) { 28 | res := lib.UniqBy(tt.vals, tt.fn) 29 | assert.ElementsMatch(t, tt.expected, res) 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "github.com/mrasu/GravityR/cmd" 6 | "github.com/mrasu/GravityR/injection" 7 | "github.com/mrasu/GravityR/lib" 8 | "net/http" 9 | ) 10 | 11 | //go:embed client/dist/assets/* 12 | var clientDist embed.FS 13 | 14 | var ( 15 | version = "0.0.0" 16 | commit = "none" 17 | ) 18 | 19 | func main() { 20 | injection.ClientDist = clientDist 21 | injection.SetBinInfo(version, commit) 22 | 23 | http.DefaultTransport = lib.NewHttpTransport() 24 | 25 | cmd.Execute() 26 | } 27 | -------------------------------------------------------------------------------- /otel/jaeger/trace_fetcher.go: -------------------------------------------------------------------------------- 1 | package jaeger 2 | 3 | import ( 4 | "github.com/gogo/protobuf/types" 5 | "github.com/jaegertracing/jaeger/proto-gen/api_v3" 6 | "github.com/mrasu/GravityR/infra/jaeger" 7 | "github.com/mrasu/GravityR/lib" 8 | "github.com/mrasu/GravityR/otel/omodel" 9 | "github.com/rs/zerolog/log" 10 | "time" 11 | ) 12 | 13 | type TraceFetcher struct { 14 | cli *jaeger.Client 15 | } 16 | 17 | func NewTraceFetcher(cli *jaeger.Client) *TraceFetcher { 18 | return &TraceFetcher{cli: cli} 19 | } 20 | 21 | func (tf *TraceFetcher) FetchCompactedTraces(size int32, start, end time.Time, serviceName string, durationMin time.Duration) ([]*omodel.TraceTree, error) { 22 | interval := 30 * time.Minute 23 | 24 | var res []*omodel.TraceTree 25 | times := lib.GenerateTimeRanges(start, end, interval) 26 | for _, start := range times { 27 | param := &api_v3.TraceQueryParameters{ 28 | ServiceName: serviceName, 29 | StartTimeMin: &types.Timestamp{Seconds: start.Unix()}, 30 | StartTimeMax: &types.Timestamp{Seconds: start.Add(interval).Unix()}, 31 | NumTraces: size, 32 | DurationMin: tf.toProtoDuration(durationMin), 33 | } 34 | log.Info().Msgf("Getting data for %s", start.Format(time.RFC3339)) 35 | 36 | trees, err := tf.findCompactedTraceTrees(param) 37 | if err != nil { 38 | return nil, err 39 | } 40 | res = append(res, trees...) 41 | } 42 | 43 | return res, nil 44 | } 45 | 46 | func (tf *TraceFetcher) toProtoDuration(duration time.Duration) *types.Duration { 47 | secs := int64(duration.Truncate(time.Second).Seconds()) 48 | nanos := int32(duration.Nanoseconds() - int64(time.Duration(secs)*time.Second)) 49 | 50 | return &types.Duration{ 51 | Seconds: secs, 52 | Nanos: nanos, 53 | } 54 | } 55 | 56 | func (tf *TraceFetcher) findCompactedTraceTrees(param *api_v3.TraceQueryParameters) ([]*omodel.TraceTree, error) { 57 | traces, err := tf.cli.FindTraces(param) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | var compactedTrees []*omodel.TraceTree 63 | for _, trace := range traces { 64 | trees := ToTraceTrees(trace.Spans) 65 | for _, tree := range trees { 66 | compacted := tree.Compact() 67 | compactedTrees = append(compactedTrees, compacted) 68 | } 69 | } 70 | 71 | return compactedTrees, nil 72 | } 73 | -------------------------------------------------------------------------------- /otel/jaeger/trace_picker.go: -------------------------------------------------------------------------------- 1 | package jaeger 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/lib" 5 | "github.com/mrasu/GravityR/otel/omodel" 6 | "github.com/samber/lo" 7 | ) 8 | 9 | type TracePicker struct { 10 | traces []*omodel.TraceTree 11 | } 12 | 13 | func NewTracePicker(traces []*omodel.TraceTree) *TracePicker { 14 | return &TracePicker{traces: traces} 15 | } 16 | 17 | func (tp *TracePicker) PickSameServiceAccessTraces(threshold int) []*omodel.TraceTree { 18 | return lo.Filter(tp.traces, func(tree *omodel.TraceTree, _ int) bool { 19 | return tree.MaxSameServiceAccessCount() > threshold 20 | }) 21 | } 22 | 23 | func (tp *TracePicker) PickSlowTraces(num int) []*omodel.TraceTree { 24 | slowTraces := lib.NewLimitedHeap[*omodel.TraceTree](num, func(a1, a2 *omodel.TraceTree) bool { 25 | d1 := a1.Root.EndTimeUnixNano - a1.Root.StartTimeUnixNano 26 | d2 := a2.Root.EndTimeUnixNano - a2.Root.StartTimeUnixNano 27 | return d1 < d2 28 | }) 29 | 30 | for _, tree := range tp.traces { 31 | slowTraces.Push(tree) 32 | } 33 | 34 | return lo.Reverse(slowTraces.PopAll()) 35 | } 36 | -------------------------------------------------------------------------------- /otel/omodel/any_value.go: -------------------------------------------------------------------------------- 1 | package omodel 2 | 3 | type AnyValue struct { 4 | Val AnyValueDatum 5 | } 6 | 7 | type AnyValueDatum interface{} 8 | 9 | type AnyValueString struct { 10 | StringValue string 11 | } 12 | type AnyValueBool struct { 13 | BoolValue bool 14 | } 15 | type AnyValueInt struct { 16 | IntValue int64 17 | } 18 | type AnyValueDouble struct { 19 | DoubleValue float64 20 | } 21 | type AnyValueArray struct { 22 | ArrayValues []AnyValueDatum 23 | } 24 | type AnyValueKV struct { 25 | KVValue map[string]AnyValueDatum 26 | } 27 | type AnyValueBytes struct { 28 | BytesValue []byte 29 | } 30 | 31 | func (m *AnyValue) GetStringValue() string { 32 | if x, ok := m.Val.(*AnyValueString); ok { 33 | return x.StringValue 34 | } 35 | return "" 36 | } 37 | 38 | func (m *AnyValue) GetBoolValue() bool { 39 | if x, ok := m.Val.(*AnyValueBool); ok { 40 | return x.BoolValue 41 | } 42 | return false 43 | } 44 | 45 | func (m *AnyValue) GetIntValue() int64 { 46 | if x, ok := m.Val.(*AnyValueInt); ok { 47 | return x.IntValue 48 | } 49 | return 0 50 | } 51 | 52 | func (m *AnyValue) GetDoubleValue() float64 { 53 | if x, ok := m.Val.(*AnyValueDouble); ok { 54 | return x.DoubleValue 55 | } 56 | return 0 57 | } 58 | 59 | func (m *AnyValue) GetArrayValue() []AnyValueDatum { 60 | if x, ok := m.Val.(*AnyValueArray); ok { 61 | return x.ArrayValues 62 | } 63 | return nil 64 | } 65 | 66 | func (m *AnyValue) GetKV() map[string]AnyValueDatum { 67 | if x, ok := m.Val.(*AnyValueKV); ok { 68 | return x.KVValue 69 | } 70 | return nil 71 | } 72 | 73 | func (m *AnyValue) GetBytesValue() []byte { 74 | if x, ok := m.Val.(*AnyValueBytes); ok { 75 | return x.BytesValue 76 | } 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /otel/omodel/compact_info_collect_visitor.go: -------------------------------------------------------------------------------- 1 | package omodel 2 | 3 | type traceInfoCollectVisitor struct { 4 | countVisitor *sameResourceCountVisitor 5 | serviceConsumedTime map[string]uint64 6 | } 7 | 8 | func (v *traceInfoCollectVisitor) Enter(span *Span) { 9 | v.countVisitor.Enter(span) 10 | v.serviceConsumedTime[span.ServiceName] += span.CalcConsumedTime() 11 | } 12 | 13 | func (v *traceInfoCollectVisitor) Leave(span *Span) { 14 | v.countVisitor.Leave(span) 15 | } 16 | 17 | func (v *traceInfoCollectVisitor) GetMaxResourceAccessCount() int { 18 | return v.countVisitor.GetMax() 19 | } 20 | 21 | func (v *traceInfoCollectVisitor) GetMostTimeConsumedService() string { 22 | maxService := "" 23 | maxTime := uint64(0) 24 | 25 | for s, t := range v.serviceConsumedTime { 26 | if maxTime < t { 27 | maxService = s 28 | maxTime = t 29 | } 30 | } 31 | 32 | return maxService 33 | } 34 | -------------------------------------------------------------------------------- /otel/omodel/same_resource_count_visitor.go: -------------------------------------------------------------------------------- 1 | package omodel 2 | 3 | import ( 4 | "github.com/samber/lo" 5 | ) 6 | 7 | type sameResourceCountVisitor struct { 8 | visitedCounts map[string]int 9 | } 10 | 11 | func (v *sameResourceCountVisitor) Enter(span *Span) { 12 | service := span.ServiceName 13 | v.visitedCounts[service] += 1 14 | } 15 | 16 | func (v *sameResourceCountVisitor) Leave(*Span) {} 17 | 18 | func (v *sameResourceCountVisitor) GetMax() int { 19 | return lo.Max(lo.Values(v.visitedCounts)) 20 | } 21 | -------------------------------------------------------------------------------- /otel/omodel/trace_compacter.go: -------------------------------------------------------------------------------- 1 | package omodel 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mrasu/GravityR/lib" 6 | ) 7 | 8 | type traceCompacter struct { 9 | parentStack *lib.Stack[Span] 10 | } 11 | 12 | func newTraceCompacter(top *Span) *traceCompacter { 13 | return &traceCompacter{ 14 | parentStack: lib.NewStack[Span](top), 15 | } 16 | } 17 | 18 | func (c *traceCompacter) Enter(span *Span) { 19 | if !c.remains(span) { 20 | return 21 | } 22 | 23 | newParent := span.ShallowCopyWithoutDependency() 24 | if span.IsLastDbSpan() { 25 | newParent.ServiceName = fmt.Sprintf("%s (%s)", span.GetDBSystem(), newParent.ServiceName) 26 | } 27 | 28 | parent := c.parentStack.Top() 29 | if parent != nil { 30 | parent.Children = append(parent.Children, newParent) 31 | } 32 | 33 | c.parentStack.Push(newParent) 34 | } 35 | 36 | func (c *traceCompacter) Leave(span *Span) { 37 | if !c.parentStack.Top().IsSame(span) { 38 | return 39 | } 40 | 41 | c.parentStack.Pop() 42 | } 43 | 44 | func (c *traceCompacter) remains(span *Span) bool { 45 | if span.Parent == nil { 46 | return true 47 | } 48 | if span.Parent.ServiceName != span.ServiceName { 49 | return true 50 | } 51 | if span.IsLastDbSpan() { 52 | return true 53 | } 54 | 55 | return false 56 | } 57 | -------------------------------------------------------------------------------- /otel/omodel/trace_tree.go: -------------------------------------------------------------------------------- 1 | package omodel 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mrasu/GravityR/html/viewmodel" 6 | ) 7 | 8 | type TraceTree struct { 9 | Root *Span 10 | } 11 | 12 | func NewTraceTree(root *Span) *TraceTree { 13 | return &TraceTree{Root: root} 14 | } 15 | 16 | func (tt *TraceTree) visit(v traceVisitor) { 17 | tt.visitRecursive(v, tt.Root) 18 | } 19 | 20 | func (tt *TraceTree) visitRecursive(v traceVisitor, current *Span) { 21 | v.Enter(current) 22 | for _, c := range current.Children { 23 | tt.visitRecursive(v, c) 24 | } 25 | v.Leave(current) 26 | } 27 | 28 | // Compact returns new TraceTree having only the first traces of services and the last traces going to DB which doesn't using otel. 29 | func (tt *TraceTree) Compact() *TraceTree { 30 | top := &Span{} 31 | tt.visit(newTraceCompacter(top)) 32 | 33 | if len(top.Children) > 0 { 34 | return &TraceTree{Root: top.Children[0]} 35 | } else { 36 | return &TraceTree{Root: nil} 37 | } 38 | } 39 | 40 | func (tt *TraceTree) MaxSameServiceAccessCount() int { 41 | v := &sameResourceCountVisitor{ 42 | visitedCounts: map[string]int{}, 43 | } 44 | tt.visit(v) 45 | 46 | return v.GetMax() 47 | } 48 | 49 | func (tt *TraceTree) ToCompactViewModel() *viewmodel.VmOtelCompactTrace { 50 | v := &traceInfoCollectVisitor{ 51 | countVisitor: &sameResourceCountVisitor{ 52 | visitedCounts: map[string]int{}, 53 | }, 54 | serviceConsumedTime: map[string]uint64{}, 55 | } 56 | tt.visit(v) 57 | 58 | return &viewmodel.VmOtelCompactTrace{ 59 | TraceId: fmt.Sprintf("%x", tt.Root.TraceId), 60 | SameServiceAccessCount: v.GetMaxResourceAccessCount(), 61 | TimeConsumingServiceName: v.GetMostTimeConsumedService(), 62 | Root: tt.Root.ToViewModel(), 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /otel/omodel/trace_visitor.go: -------------------------------------------------------------------------------- 1 | package omodel 2 | 3 | type traceVisitor interface { 4 | Enter(*Span) 5 | Leave(*Span) 6 | } 7 | -------------------------------------------------------------------------------- /thelper/bytes.go: -------------------------------------------------------------------------------- 1 | package thelper 2 | 3 | import ( 4 | "encoding/hex" 5 | "github.com/stretchr/testify/require" 6 | "testing" 7 | ) 8 | 9 | func DecodeHex(t *testing.T, h string) []byte { 10 | t.Helper() 11 | 12 | res, err := hex.DecodeString(h) 13 | require.NoError(t, err) 14 | return res 15 | } 16 | -------------------------------------------------------------------------------- /thelper/db.go: -------------------------------------------------------------------------------- 1 | package thelper 2 | 3 | import ( 4 | "github.com/DATA-DOG/go-sqlmock" 5 | "github.com/jmoiron/sqlx" 6 | "github.com/mrasu/GravityR/infra/mysql" 7 | "github.com/mrasu/GravityR/infra/postgres" 8 | "github.com/stretchr/testify/assert" 9 | "testing" 10 | 11 | _ "github.com/go-sql-driver/mysql" 12 | ) 13 | 14 | func MockMysqlDB(t *testing.T, f func(*mysql.DB, sqlmock.Sqlmock)) { 15 | mockDB(t, "mysql", func(db *sqlx.DB, mock sqlmock.Sqlmock) { 16 | f(mysql.NewDB(db), mock) 17 | }) 18 | } 19 | 20 | func MockPostgresDB(t *testing.T, f func(*postgres.DB, sqlmock.Sqlmock)) { 21 | mockDB(t, "postgres", func(db *sqlx.DB, mock sqlmock.Sqlmock) { 22 | f(postgres.NewDB(db), mock) 23 | }) 24 | } 25 | 26 | func mockDB(t *testing.T, driver string, f func(*sqlx.DB, sqlmock.Sqlmock)) { 27 | t.Helper() 28 | mockdb, mock, err := sqlmock.New() 29 | assert.NoError(t, err) 30 | defer mockdb.Close() 31 | db := sqlx.NewDb(mockdb, driver) 32 | f(db, mock) 33 | assert.NoError(t, mock.ExpectationsWereMet()) 34 | } 35 | -------------------------------------------------------------------------------- /thelper/file.go: -------------------------------------------------------------------------------- 1 | package thelper 2 | 3 | import ( 4 | "github.com/stretchr/testify/require" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "testing" 9 | ) 10 | 11 | func ReadFromFiles(t *testing.T, filename string) string { 12 | t.Helper() 13 | 14 | _, b, _, _ := runtime.Caller(0) 15 | p := filepath.Join(filepath.Dir(b), "../", "./testdata/files/", filename) 16 | txt, err := os.ReadFile(p) 17 | require.NoError(t, err) 18 | 19 | return string(txt) 20 | } 21 | -------------------------------------------------------------------------------- /thelper/injection.go: -------------------------------------------------------------------------------- 1 | package thelper 2 | 3 | import ( 4 | "github.com/mrasu/GravityR/injection" 5 | "testing/fstest" 6 | ) 7 | 8 | func InjectClientDist() { 9 | injection.ClientDist = fstest.MapFS{ 10 | "client/dist/assets/main.js": { 11 | Data: []byte("console.log('hello')"), 12 | }, 13 | "client/dist/assets/main.css": { 14 | Data: []byte("body{}"), 15 | }, 16 | "client/dist/assets/mermaid.js": { 17 | Data: []byte("console.log('hello')"), 18 | }, 19 | "client/dist/assets/mermaid.css": { 20 | Data: []byte("body{}"), 21 | }, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /thelper/jaeger.go: -------------------------------------------------------------------------------- 1 | package thelper 2 | 3 | import ( 4 | "github.com/jaegertracing/jaeger/cmd/query/app/apiv3" 5 | "github.com/jaegertracing/jaeger/cmd/query/app/querysvc" 6 | "github.com/jaegertracing/jaeger/proto-gen/api_v3" 7 | common "github.com/jaegertracing/jaeger/proto-gen/otel/common/v1" 8 | v1 "github.com/jaegertracing/jaeger/proto-gen/otel/resource/v1" 9 | "github.com/jaegertracing/jaeger/storage/spanstore" 10 | "github.com/stretchr/testify/require" 11 | semconv "go.opentelemetry.io/otel/semconv/v1.12.0" 12 | "google.golang.org/grpc" 13 | "net" 14 | "testing" 15 | ) 16 | 17 | func UseGRPCServer(t *testing.T, reader spanstore.Reader, fn func(net.Addr)) { 18 | t.Helper() 19 | 20 | s := grpc.NewServer() 21 | qs := querysvc.NewQueryService( 22 | reader, nil, querysvc.QueryServiceOptions{}, 23 | ) 24 | api_v3.RegisterQueryServiceServer(s, &apiv3.Handler{QueryService: qs}) 25 | lis, err := net.Listen("tcp", ":0") 26 | require.NoError(t, err) 27 | go func() { 28 | err := s.Serve(lis) 29 | require.NoError(t, err) 30 | }() 31 | defer s.Stop() 32 | 33 | fn(lis.Addr()) 34 | } 35 | 36 | func BuildJaegerResource(serviceName string) *v1.Resource { 37 | return &v1.Resource{ 38 | Attributes: []*common.KeyValue{ 39 | { 40 | Key: string(semconv.ServiceNameKey), 41 | Value: &common.AnyValue{ 42 | Value: &common.AnyValue_StringValue{StringValue: serviceName}, 43 | }, 44 | }, 45 | }, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /thelper/setup.go: -------------------------------------------------------------------------------- 1 | package thelper 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | var DBName string 8 | var MySQLDsn string 9 | 10 | func SetUp() { 11 | DBName = "gravityr" 12 | MySQLDsn = fmt.Sprintf("%s@/%s", "root", DBName) 13 | } 14 | -------------------------------------------------------------------------------- /thelper/temp.go: -------------------------------------------------------------------------------- 1 | package thelper 2 | 3 | import ( 4 | "github.com/stretchr/testify/require" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func CreateTemp(t *testing.T, pattern string, fn func(*os.File)) { 10 | t.Helper() 11 | 12 | tmpfile, err := os.CreateTemp("", pattern) 13 | require.NoError(t, err) 14 | defer os.Remove(tmpfile.Name()) 15 | 16 | fn(tmpfile) 17 | } 18 | --------------------------------------------------------------------------------