├── .env.template ├── .eslintrc ├── .github └── workflows │ ├── artillery.yml │ ├── community-issue-notification.yml │ ├── community-pr-notification.yml │ ├── cypress-stage.yml │ ├── cypress.yml │ ├── docker.yaml │ ├── k6-stage.yml │ └── k6.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── Makefile ├── README.md ├── api ├── .env ├── .gitignore ├── .npmrc ├── .nvmrc ├── config.local.json ├── migrations │ └── 01-pokemon-table.js ├── package-lock.json ├── package.json ├── protos │ └── pokeshop.proto ├── src │ ├── api.ts │ ├── constants │ │ └── Tags.ts │ ├── graphql │ │ ├── create.resolver.ts │ │ ├── get.resolver.ts │ │ ├── import.resolver.ts │ │ └── resolvers.ts │ ├── handlers │ │ ├── create.handler.ts │ │ ├── featured.handler.ts │ │ ├── get.handler.ts │ │ ├── getbyid.handler.ts │ │ ├── healthcheck.handler.ts │ │ ├── import.handler.ts │ │ ├── remove.handler.ts │ │ ├── search.handler.ts │ │ ├── streamSyncronize.handler.ts │ │ ├── syncronize.handler.ts │ │ └── update.handler.ts │ ├── middlewares │ │ ├── instrumentation.ts │ │ ├── response.ts │ │ └── validation.ts │ ├── protos │ │ └── pokeshop.ts │ ├── repositories │ │ ├── index.ts │ │ ├── instrumented.repository.ts │ │ ├── pokemon.repository.ts │ │ └── pokemon.sequelize.repository.ts │ ├── rpc.ts │ ├── schema.ts │ ├── scripts │ │ ├── produceStreamMessage.ts │ │ └── saveConfig.ts │ ├── services │ │ ├── cache.service.ts │ │ ├── fetured.service.ts │ │ ├── pokeApi.service.ts │ │ ├── pokemonRpc.service.ts │ │ ├── pokemonSyncronizer.service.ts │ │ ├── queue.service.ts │ │ └── stream.service.ts │ ├── streamWorker.ts │ ├── telemetry │ │ ├── instrumented.component.ts │ │ ├── setup.ts │ │ └── tracing.ts │ ├── utils │ │ └── db.ts │ ├── validators │ │ ├── createPokemon.ts │ │ ├── importPokemon.ts │ │ └── updatePokemon.ts │ └── worker.ts └── tsconfig.json ├── collector.config.yaml ├── cypress.config.ts ├── cypress ├── e2e │ └── 1-getting-started │ │ └── home.cy.ts └── support │ ├── commands.js │ └── e2e.js ├── docker-compose.e2e.yml ├── docker-compose.k6.workflows.yml ├── docker-compose.k6.yml ├── docker-compose.stream.yml ├── docker-compose.yml ├── docs ├── diagrams │ ├── api-create-pokemon.mdd │ ├── api-create-pokemon.png │ ├── api-get-pokemon.mdd │ ├── api-get-pokemon.png │ ├── api-import-pokemon.mdd │ ├── api-import-pokemon.png │ ├── worker-import-pokemon.mdd │ └── worker-import-pokemon.png ├── installing.md └── overview.md ├── helm-chart ├── .helmignore ├── Chart.lock ├── Chart.yaml ├── readme.md ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── deployment.yaml │ ├── hpa.yaml │ ├── ingress.yaml │ ├── service.yaml │ ├── serviceaccount.yaml │ └── tests │ │ └── test-connection.yaml └── values.yaml ├── k6.Dockerfile ├── k8s ├── api.yaml ├── cache.yaml ├── db.yaml ├── jaeger.yaml ├── otel-collector.yaml ├── queue.yaml ├── readme.md ├── rpc.yaml ├── tracetest-agent.yaml └── worker.yaml ├── openapi └── openapi.yaml ├── package-lock.json ├── package.json ├── playwright.config.ts ├── playwright └── home.spec.ts ├── serverless ├── .env.template ├── .gitignore ├── .npmrc ├── .nvmrc ├── README.md ├── config.local.json ├── infra │ ├── elasticache.yml │ ├── queue.yml │ └── rds.yml ├── package-lock.json ├── package.json ├── serverless.yml ├── src │ ├── constants │ │ └── tags.ts │ ├── handler.ts │ ├── handlers │ │ ├── create.handler.ts │ │ ├── featured.handler.ts │ │ ├── get.handler.ts │ │ ├── import.handler.ts │ │ ├── remove.handler.ts │ │ ├── search.handler.ts │ │ └── worker.handler.ts │ ├── middlewares │ │ ├── db.ts │ │ ├── errorHandler.ts │ │ ├── instrumentation.ts │ │ └── middleware.ts │ ├── migrations │ │ └── 01-pokemon-table.js │ ├── repositories │ │ ├── index.ts │ │ ├── instrumented.repository.ts │ │ ├── pokemon.repository.ts │ │ └── pokemon.sequelize.repository.ts │ ├── services │ │ ├── cache.service.ts │ │ ├── fetured.service.ts │ │ ├── pokeApi.service.ts │ │ ├── pokemonSynchronizer.service.ts │ │ └── queue.service.ts │ ├── setup.ts │ ├── telemetry │ │ ├── instrumented.component.ts │ │ └── tracing.ts │ ├── utils │ │ ├── db.ts │ │ └── traces.ts │ └── validators │ │ ├── createPokemon.ts │ │ └── importPokemon.ts ├── tracetest.ts └── tsconfig.json ├── test ├── artillery │ ├── import-pokemon-definition.yml │ └── import-pokemon.yml └── k6 │ ├── add-pokemon.js │ ├── import-pokemon.js │ └── run.sh ├── tracetest ├── config │ ├── tracetest-cli.yaml │ ├── tracetest-config.yaml │ └── tracetest-provision.yaml ├── docker-compose.yml └── tests │ ├── add.yaml │ ├── importqueue.yaml │ ├── importstream.yaml │ ├── list.yaml │ └── testsuite.yaml ├── tsconfig.json └── web ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── manifest.json └── robots.txt ├── src ├── App.css ├── App.test.tsx ├── App.tsx ├── assets │ └── Logo.svg ├── components │ ├── CreateModal │ │ ├── CreateModal.tsx │ │ └── index.ts │ ├── Header │ │ ├── Header.styled.tsx │ │ ├── Header.tsx │ │ ├── HeaderMenu.tsx │ │ └── index.ts │ ├── ImportModal │ │ ├── ImportModal.tsx │ │ └── index.ts │ ├── Layout │ │ ├── Layout.styled.ts │ │ ├── Layout.tsx │ │ └── index.ts │ ├── PokemonCard │ │ ├── PokemonCard.styled.ts │ │ ├── PokemonCard.tsx │ │ └── index.ts │ ├── PokemonList │ │ ├── PokemonList.styled.ts │ │ ├── PokemonList.tsx │ │ └── index.ts │ └── Router │ │ ├── Router.tsx │ │ └── index.tsx ├── constants │ ├── common.ts │ └── theme.ts ├── gateways │ └── pokemon.ts ├── hooks │ └── usePokemonCrud.ts ├── index.css ├── index.tsx ├── pages │ └── Home │ │ ├── Home.styled.tsx │ │ ├── Home.tsx │ │ ├── HomeActions.tsx │ │ └── index.ts ├── react-app-env.d.ts ├── reportWebVitals.ts ├── setupTests.ts ├── styled.d.ts ├── types │ ├── generated.ts │ └── pokemon.ts └── utils │ ├── loadConfig.ts │ ├── request.ts │ └── tracer.ts └── tsconfig.json /.env.template: -------------------------------------------------------------------------------- 1 | TRACETEST_API_TOKEN= 2 | POKESHOP_DEMO_URL=http://localhost:8081 3 | TRACETEST_AGENT_API_KEY= 4 | TRACETEST_ENVIRONMENT_ID= -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:@typescript-eslint/recommended"], 3 | "plugins": ["@typescript-eslint"], 4 | "root": true, 5 | "globals": {}, 6 | "rules": { 7 | "quotes": ["error", "single", { "avoidEscape": true }], 8 | "no-unused-vars": "off", 9 | "@typescript-eslint/no-explicit-any": "off", 10 | "max-len": [ 11 | "error", 12 | { 13 | "code": 150, 14 | "ignoreComments": true, 15 | "ignoreTrailingComments": true, 16 | "ignoreUrls": true, 17 | "ignoreStrings": true, 18 | "ignoreTemplateLiterals": true 19 | } 20 | ], 21 | "@typescript-eslint/ban-types": [ 22 | "warn", 23 | { 24 | "types": { 25 | "Function": null 26 | } 27 | } 28 | ], 29 | "@typescript-eslint/no-this-alias": "off" 30 | }, 31 | "parser": "@typescript-eslint/parser", 32 | "env": {}, 33 | "overrides": [] 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/artillery.yml: -------------------------------------------------------------------------------- 1 | name: Artillery Tests 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '0 */2 * * *' # every 2 hours 6 | 7 | jobs: 8 | artillery: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v2 14 | 15 | - name: Install 16 | run: npm ci 17 | 18 | - name: Execute load tests 19 | uses: artilleryio/action-cli@v1 20 | with: 21 | command: run ./test/artillery/import-pokemon.yml 22 | env: 23 | ARTILLERY_PLUGIN_PATH: ${{ github.workspace }}/node_modules/ 24 | - name: Send message on Slack in case of failure 25 | if: ${{ failure() }} 26 | uses: slackapi/slack-github-action@v1.24.0 27 | with: 28 | # check the block kit builder docs to understand how it works 29 | # and how to modify it: https://api.slack.com/block-kit 30 | payload: | 31 | { 32 | "text": ":warning: Synthetic Monitoring failed for *Artillery with Pokeshop*. <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow>" 33 | } 34 | env: 35 | SLACK_WEBHOOK_URL: ${{ secrets.SYNTETIC_MONITORING_SLACK_WEBHOOK_URL }} 36 | SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK 37 | -------------------------------------------------------------------------------- /.github/workflows/community-issue-notification.yml: -------------------------------------------------------------------------------- 1 | # Send us messages on slack when users from the community either open new issues or PRs 2 | on: 3 | issues: 4 | types: 5 | - opened 6 | 7 | jobs: 8 | notify_slack: 9 | name: Notify team 10 | runs-on: ubuntu-latest 11 | steps: 12 | 13 | - name: Check if it's team member 14 | id: is_team_member 15 | if: github.event.action == 'opened' 16 | uses: mathnogueira/user-blocklist@1.0.0 17 | with: 18 | blocked_users: adnanrahic, jfermi, jorgeepc, kdhamric, mathnogueira, olha23, schoren, xoscar, danielbdias 19 | 20 | - name: Notify us if not a kubeshop member 21 | if: | 22 | steps.is_team_member.outputs.result == 'false' 23 | uses: rtCamp/action-slack-notify@v2 24 | env: 25 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 26 | SLACK_CHANNEL: tracetest 27 | SLACK_COLOR: good 28 | SLACK_ICON: https://github.com/rtCamp.png?size=48 29 | SLACK_TITLE: An issue was opened by a user 30 | SLACK_MESSAGE: ${{ github.event.issue.title }} 31 | SLACK_USERNAME: GitHub 32 | SLACK_LINK_NAMES: true 33 | SLACK_FOOTER: ${{ github.event.issue.html_url }} 34 | MSG_MINIMAL: true 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/community-pr-notification.yml: -------------------------------------------------------------------------------- 1 | # Send us messages on slack when users from the community either open new issues or PRs 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | 7 | jobs: 8 | notify_slack: 9 | name: Notify team 10 | runs-on: ubuntu-latest 11 | steps: 12 | 13 | - name: Check if it's team member 14 | id: is_team_member 15 | if: github.event.action == 'opened' 16 | uses: mathnogueira/user-blocklist@1.0.0 17 | with: 18 | blocked_users: cescoferraro, jfermi, jorgeepc, kdhamric, mathnogueira, olha23, schoren, xoscar, danielbdias 19 | 20 | - name: Notify us if not a kubeshop member 21 | if: | 22 | steps.is_team_member.outputs.result == 'false' 23 | uses: rtCamp/action-slack-notify@v2 24 | env: 25 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 26 | SLACK_CHANNEL: tracetest 27 | SLACK_COLOR: good 28 | SLACK_ICON: https://github.com/rtCamp.png?size=48 29 | SLACK_TITLE: A PR was opened by a user 30 | SLACK_MESSAGE: ${{ github.event.pull_request.title }} 31 | SLACK_USERNAME: GitHub 32 | SLACK_LINK_NAMES: true 33 | SLACK_FOOTER: ${{ github.event.pull_request.html_url }} 34 | MSG_MINIMAL: true 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/cypress-stage.yml: -------------------------------------------------------------------------------- 1 | name: Cypress Tests Stage 2 | 3 | on: 4 | # allows the manual trigger 5 | workflow_dispatch: 6 | 7 | schedule: 8 | - cron: '*/15 * * * *' # Run every 20 minutes 9 | 10 | jobs: 11 | cypress-run: 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 10 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | # Install NPM dependencies, cache them correctly 18 | # and run all Cypress tests 19 | - name: Cypress run 20 | uses: cypress-io/github-action@v6 21 | env: 22 | TRACETEST_API_TOKEN: ${{secrets.TRACETEST_TOKEN_STAGE}} 23 | POKESHOP_DEMO_URL: ${{secrets.POKESHOP_DEMO_URL_STAGE}} 24 | TRACETEST_SERVER_URL: https://app-stage.tracetest.io 25 | 26 | - name: Send message on Slack in case of failure 27 | if: ${{ failure() }} 28 | uses: slackapi/slack-github-action@v1.24.0 29 | with: 30 | payload: | 31 | { 32 | "text": ":warning: Synthetic Monitoring failed for *Cypress e2e with Pokeshop* on *stage*. <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow>" 33 | } 34 | env: 35 | SLACK_WEBHOOK_URL: ${{ secrets.SYNTETIC_MONITORING_SLACK_WEBHOOK_URL_STAGE }} 36 | SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK 37 | -------------------------------------------------------------------------------- /.github/workflows/cypress.yml: -------------------------------------------------------------------------------- 1 | name: Cypress Tests 2 | 3 | on: 4 | # allows the manual trigger 5 | workflow_dispatch: 6 | schedule: 7 | - cron: '0 */2 * * *' # every 2 hours 8 | 9 | jobs: 10 | cypress-run: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 10 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | # Install NPM dependencies, cache them correctly 17 | # and run all Cypress tests 18 | - name: Cypress run 19 | uses: cypress-io/github-action@v6 20 | env: 21 | TRACETEST_API_TOKEN: ${{secrets.TRACETEST_TOKEN}} 22 | POKESHOP_DEMO_URL: ${{secrets.POKESHOP_DEMO_URL}} 23 | 24 | - name: Send message on Slack in case of failure 25 | if: ${{ failure() }} 26 | uses: slackapi/slack-github-action@v1.24.0 27 | with: 28 | payload: | 29 | { 30 | "text": ":warning: Synthetic Monitoring failed for *Cypress e2e with Pokeshop*. <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow>" 31 | } 32 | env: 33 | SLACK_WEBHOOK_URL: ${{ secrets.SYNTETIC_MONITORING_SLACK_WEBHOOK_URL }} 34 | SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK 35 | -------------------------------------------------------------------------------- /.github/workflows/docker.yaml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up QEMU 18 | uses: docker/setup-qemu-action@v2 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v2 21 | - name: Login to DockerHub 22 | uses: docker/login-action@v2 23 | with: 24 | username: ${{ secrets.DOCKERHUB_USERNAME }} 25 | password: ${{ secrets.DOCKERHUB_TOKEN }} 26 | - name: Build and push 27 | uses: docker/build-push-action@v3 28 | with: 29 | context: . 30 | platforms: linux/amd64,linux/arm64 31 | push: true 32 | cache-from: type=gha 33 | cache-to: type=gha,mode=max 34 | tags: | 35 | kubeshop/demo-pokemon-api:latest 36 | kubeshop/demo-pokemon-api:test 37 | -------------------------------------------------------------------------------- /.github/workflows/k6-stage.yml: -------------------------------------------------------------------------------- 1 | name: K6 Tests Stage 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '*/15 * * * *' # Run every 20 minutes 6 | 7 | jobs: 8 | docker: 9 | timeout-minutes: 10 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v1 15 | 16 | - name: Start containers 17 | run: docker compose -f docker-compose.k6.workflows.yml run k6-tracetest 18 | env: 19 | TRACETEST_API_TOKEN: ${{secrets.TRACETEST_TOKEN_STAGE}} 20 | POKESHOP_DEMO_URL: ${{secrets.POKESHOP_DEMO_URL_STAGE}} 21 | TRACETEST_SERVER_URL: https://app-stage.tracetest.io 22 | 23 | - name: Stop containers 24 | if: always() 25 | run: docker compose -f docker-compose.k6.workflows.yml down 26 | - name: Send message on Slack in case of failure 27 | if: ${{ failure() }} 28 | uses: slackapi/slack-github-action@v1.24.0 29 | with: 30 | payload: | 31 | { 32 | "text": ":warning: Synthetic Monitoring failed for *K6 with Pokeshop* on *stage*. <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow>" 33 | } 34 | env: 35 | SLACK_WEBHOOK_URL: ${{ secrets.SYNTETIC_MONITORING_SLACK_WEBHOOK_URL_STAGE }} 36 | SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK 37 | -------------------------------------------------------------------------------- /.github/workflows/k6.yml: -------------------------------------------------------------------------------- 1 | name: K6 Tests 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '0 */2 * * *' # every two hours 6 | 7 | jobs: 8 | docker: 9 | timeout-minutes: 10 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v1 15 | 16 | - name: Start containers 17 | run: docker compose -f docker-compose.k6.workflows.yml run k6-tracetest 18 | env: 19 | TRACETEST_API_TOKEN: ${{secrets.TRACETEST_TOKEN}} 20 | POKESHOP_DEMO_URL: ${{secrets.POKESHOP_DEMO_URL}} 21 | 22 | - name: Stop containers 23 | if: always() 24 | run: docker compose -f docker-compose.k6.workflows.yml down 25 | - name: Send message on Slack in case of failure 26 | if: ${{ failure() }} 27 | uses: slackapi/slack-github-action@v1.24.0 28 | with: 29 | payload: | 30 | { 31 | "text": ":warning: Synthetic Monitoring failed for *K6 with Pokeshop*. <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow>" 32 | } 33 | env: 34 | SLACK_WEBHOOK_URL: ${{ secrets.SYNTETIC_MONITORING_SLACK_WEBHOOK_URL }} 35 | SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | # Downloaded charts 4 | helm-chart/charts/ 5 | 6 | node_modules/ 7 | api/.build/ 8 | .idea/ 9 | .DS_Store 10 | .env 11 | 12 | cypress/screenshots 13 | cypress/downloads 14 | /test-results/ 15 | /playwright-report/ 16 | /blob-report/ 17 | /playwright/.cache/ 18 | serverless/.env 19 | 20 | # k6 executable 21 | /k6 22 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .git 3 | build 4 | dist 5 | .husky 6 | node_modules 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "importOrderSeparation": true, 3 | "importOrderSortSpecifiers": true, 4 | "singleQuote": true, 5 | "arrowParens": "avoid", 6 | "bracketSpacing": true, 7 | "semi": true, 8 | "trailingComma": "es5", 9 | "printWidth": 120, 10 | "jsxBracketSameLine": false, 11 | "proseWrap": "always", 12 | "quoteProps": "as-needed", 13 | "tabWidth": 2, 14 | "useTabs": false 15 | } 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.5.1-alpine as build-ui 2 | 3 | WORKDIR /ui 4 | ENV PATH /ui/node_modules/.bin:$PATH 5 | COPY ./web/package.json ./ 6 | COPY ./web/package-lock.json ./ 7 | 8 | RUN npm ci --silent 9 | COPY ./web ./ 10 | 11 | RUN npm run build 12 | 13 | # build 14 | FROM node:20-alpine as build 15 | 16 | WORKDIR /build 17 | RUN npm i -g typescript 18 | COPY ./api/package*.json ./ 19 | RUN npm ci 20 | 21 | COPY ./api ./ 22 | 23 | RUN npm run build 24 | 25 | # app 26 | FROM node:20-alpine as app 27 | 28 | WORKDIR /app 29 | COPY ./api/package.json ./api/package-lock.json ./ 30 | RUN npm clean-install 31 | 32 | EXPOSE 80 33 | EXPOSE 8081 34 | EXPOSE 8082 35 | 36 | ENV NPM_RUN_COMMAND=api 37 | 38 | COPY --from=build /build/.build/* ./ 39 | COPY --from=build /build/migrations/* ./migrations/ 40 | COPY --from=build-ui /ui/build ./ui 41 | 42 | CMD ["sh", "-c", "npm run $NPM_RUN_COMMAND"] 43 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SRCDIR = docs/diagrams 2 | OUTPUT_FORMAT = png 3 | SRC = $(wildcard $(SRCDIR)/*.mdd) 4 | OUT = ${SRC:.mdd=.$(OUTPUT_FORMAT)} 5 | DETACHED ?= false 6 | BUILD ?= false 7 | export FLAGS 8 | 9 | help: Makefile ## show list of commands 10 | @echo "Choose a command run:" 11 | @echo "" 12 | @awk 'BEGIN {FS = ":.*?## "} /[a-zA-Z_-]+:.*?## / {sub("\\\\n",sprintf("\n%22c"," "), $$2);printf "\033[36m%-40s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | sort 13 | 14 | ifeq ($(DETACHED),true) 15 | FLAGS+= --detach 16 | endif 17 | 18 | ifeq ($(BUILD),true) 19 | FLAGS+= --build 20 | endif 21 | 22 | generate-diagrams: $(OUT) 23 | 24 | $(SRCDIR)/%.$(OUTPUT_FORMAT): $(SRCDIR)/%.mdd 25 | npm run generate-diagram -- --input $< --output $@ 26 | 27 | run/pokeshop: ## run Pokeshop API on docker compose 28 | docker compose -f docker-compose.yml -f ./docker-compose.stream.yml up ${FLAGS} 29 | 30 | down/pokeshop: ## stop Pokeshop API running on docker compose 31 | docker compose -f docker-compose.yml -f ./docker-compose.stream.yml down 32 | 33 | run/tracetests: ## run Trace-based tests on Pokeshop API with Tracetest 34 | docker compose -f docker-compose.yml -f ./docker-compose.stream.yml -f ./tracetest/docker-compose.yml run tracebased-tests 35 | 36 | run: ## run Pokeshop API on Docker Compose and run Trace-based tests with Tracetest 37 | docker compose -f docker-compose.yml -f ./docker-compose.stream.yml -f ./tracetest/docker-compose.yml up ${FLAGS} 38 | 39 | down: ## stop Pokeshop API on Docker Compose and run Trace-based tests with Tracetest 40 | docker compose -f docker-compose.yml -f ./docker-compose.stream.yml -f ./tracetest/docker-compose.yml down 41 | 42 | build/docker: # build docker image locally 43 | docker build . -t kubeshop/demo-pokemon-api:latest -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pokeshop 2 | 3 | This is a demo application instrumented with Open Telemetry to generate traces. 4 | It was created to be used in our demo page of [Tracetest](https://github.com/kubeshop/tracetest). 5 | 6 | You can see a detailed explanation of Tracetest documentation: https://docs.tracetest.io/live-examples/pokeshop/overview 7 | 8 | ## Additional information 9 | 10 | 1. [API Overview](https://github.com/kubeshop/pokeshop/blob/master/docs/overview.md) 11 | 2. [Installation](https://github.com/kubeshop/pokeshop/blob/master/docs/installing.md) 12 | 3. [OpenAPI specs](https://github.com/kubeshop/pokeshop/blob/master/openapi/openapi.yaml) -------------------------------------------------------------------------------- /api/.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgresql://ashketchum:squirtle123@localhost:5434/pokeshop?schema=public" 2 | REDIS_URL=localhost 3 | RABBITMQ_HOST=localhost 4 | POKE_API_BASE_URL=https://pokeapi.co/api/v2 5 | COLLECTOR_ENDPOINT=http://localhost:4317 6 | APP_PORT=8081 7 | RPC_PORT=8082 8 | KAFKA_BROKER=localhost:29092 9 | KAFKA_TOPIC=pokemon 10 | KAFKA_CLIENT_ID=streaming-worker 11 | HTTP_COLLECTOR_ENDPOINT=http://localhost:4318/v1/traces 12 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless -------------------------------------------------------------------------------- /api/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true -------------------------------------------------------------------------------- /api/.nvmrc: -------------------------------------------------------------------------------- 1 | v14.17.0 2 | -------------------------------------------------------------------------------- /api/config.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "DATABASE_URL": "postgresql://admin:admin@localhost:5432/pokeshop?schema=public" 3 | } 4 | -------------------------------------------------------------------------------- /api/migrations/01-pokemon-table.js: -------------------------------------------------------------------------------- 1 | const { Sequelize } = require('sequelize'); 2 | 3 | module.exports = { 4 | up: async ({ context: queryInterface }) => { 5 | return await queryInterface.createTable('pokemon', { 6 | id: { 7 | type: Sequelize.DataTypes.INTEGER, 8 | autoIncrement: true, 9 | primaryKey: true 10 | }, 11 | name: Sequelize.DataTypes.STRING, 12 | type: Sequelize.DataTypes.STRING, 13 | isFeatured: { 14 | type: Sequelize.DataTypes.BOOLEAN, 15 | defaultValue: false, 16 | allowNull: false 17 | }, 18 | imageUrl: { 19 | type: Sequelize.DataTypes.STRING, 20 | allowNull: true 21 | }, 22 | createdAt: { 23 | type: Sequelize.DataTypes.DATE, 24 | allowNull: false, 25 | defaultValue: Sequelize.DataTypes.NOW 26 | }, 27 | updatedAt: { 28 | type: Sequelize.DataTypes.DATE, 29 | allowNull: true 30 | }, 31 | }); 32 | }, 33 | down: async ({ context: queryInterface }) => { 34 | return await queryInterface.dropTable('pokemon'); 35 | } 36 | }; -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PokeshopAPI", 3 | "version": "1.0.0", 4 | "description": "This is a demo application instrumented with Open Telemetry to generate traces based on PokeAPI", 5 | "main": "handler.js", 6 | "scripts": { 7 | "generate:proto": "cd ./protos && protoc --plugin=../node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=../src/protos --ts_proto_opt=outputServices=grpc-js ./pokeshop.proto", 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "start": "nodemon --watch 'src/**' --exec 'ts-node -r tsconfig-paths/register -r ./src/telemetry/setup.ts ./src/api.ts'", 10 | "start:worker": "nodemon --watch 'src/**' --exec 'ts-node -r tsconfig-paths/register -r ./src/telemetry/setup.ts ./src/worker.ts'", 11 | "start:stream-worker": "nodemon --watch 'src/**' --exec 'ts-node -r tsconfig-paths/register -r ./src/telemetry/setup.ts ./src/streamWorker.ts'", 12 | "start:rpc": "nodemon --watch 'src/**' --exec 'ts-node -r tsconfig-paths/register -r ./src/telemetry/setup.ts ./src/rpc.ts'", 13 | "build": "tsc --project tsconfig.json && tspath -f", 14 | "saveConfig": "node scripts/saveConfig.js", 15 | "rpc": "node --require ./telemetry/setup.js rpc.js", 16 | "api": "npm run saveConfig && node --require ./telemetry/setup.js api.js", 17 | "worker": "node --require ./telemetry/setup.js worker.js", 18 | "stream-worker": "node --require ./telemetry/setup.js streamWorker.js", 19 | "test:produce-stream-message": "ts-node -r tsconfig-paths/register -r ./src/scripts/produceStreamMessage.ts" 20 | }, 21 | "author": "", 22 | "license": "ISC", 23 | "devDependencies": { 24 | "@types/amqplib": "^0.8.2", 25 | "@types/aws-lambda": "^8.10.118", 26 | "@types/debug": "^4.1.8", 27 | "@types/koa": "^2.13.6", 28 | "@types/lodash": "^4.14.195", 29 | "@types/node": "^17.0.24", 30 | "@types/validator": "^13.7.17", 31 | "aws-sdk": "^2.1397.0", 32 | "node-ts": "^5.1.2", 33 | "nodemon": "^1.3.3", 34 | "ts-node": "^10.7.0", 35 | "ts-proto": "^1.149.0", 36 | "tsconfig-paths": "^3.14.2", 37 | "tspath": "^1.2.10", 38 | "typescript": "^4.9.5" 39 | }, 40 | "dependencies": { 41 | "@grpc/grpc-js": "^1.8.15", 42 | "@koa/cors": "^3.3.0", 43 | "@koa/router": "^10.1.1", 44 | "@opentelemetry/api": "^1.9.0", 45 | "@opentelemetry/auto-instrumentations-node": "^0.50.0", 46 | "@opentelemetry/exporter-jaeger": "^1.26.0", 47 | "@opentelemetry/exporter-trace-otlp-grpc": "^0.53.0", 48 | "@opentelemetry/instrumentation": "^0.53.0", 49 | "@opentelemetry/resources": "^1.26.0", 50 | "@opentelemetry/sdk-node": "^0.53.0", 51 | "@opentelemetry/sdk-trace-base": "^1.26.0", 52 | "@opentelemetry/semantic-conventions": "^1.27.0", 53 | "@types/node-fetch": "^2.6.4", 54 | "amqplib": "^0.8.0", 55 | "class-transformer": "^0.5.1", 56 | "class-transformer-validator": "^0.9.1", 57 | "class-validator": "^0.13.2", 58 | "debug": "^4.3.4", 59 | "dotenv": "^16.1.4", 60 | "file-type": "^16.5.3", 61 | "ioredis": "^4.28.5", 62 | "kafkajs": "^2.2.4", 63 | "koa": "^2.14.2", 64 | "koa-bodyparser": "^4.4.0", 65 | "koa-graphql": "^0.12.0", 66 | "koa-logger": "^3.2.1", 67 | "koa-mount": "^4.0.0", 68 | "koa-static": "^5.0.0", 69 | "lodash": "^4.17.21", 70 | "node-fetch": "^2.6.11", 71 | "pg": "^8.11.0", 72 | "reflect-metadata": "^0.1.13", 73 | "sequelize": "^6.32.0", 74 | "sequelize-typescript": "^2.1.3", 75 | "umzug": "^3.1.1" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /api/protos/pokeshop.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_multiple_files = true; 4 | option java_outer_classname = "PokeshopProto"; 5 | option objc_class_prefix = "PKS"; 6 | 7 | package pokeshop; 8 | 9 | service Pokeshop { 10 | rpc getPokemonList (GetPokemonRequest) returns (GetPokemonListResponse) {} 11 | rpc createPokemon (Pokemon) returns (Pokemon) {} 12 | rpc importPokemon (ImportPokemonRequest) returns (ImportPokemonRequest) {} 13 | } 14 | 15 | message ImportPokemonRequest { 16 | int32 id = 1; 17 | optional bool isFixed = 2; 18 | } 19 | 20 | message GetPokemonRequest { 21 | optional int32 skip = 1; 22 | optional int32 take = 2; 23 | optional bool isFixed = 3; 24 | } 25 | 26 | message GetPokemonListResponse { 27 | repeated Pokemon items = 1; 28 | int32 totalCount = 2; 29 | } 30 | 31 | message Pokemon { 32 | optional int32 id = 1; 33 | string name = 2; 34 | string type = 3; 35 | bool isFeatured = 4; 36 | optional string imageUrl = 5; 37 | } 38 | -------------------------------------------------------------------------------- /api/src/api.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa'; 2 | import Router from '@koa/router'; 3 | import serve from 'koa-static'; 4 | import mount from 'koa-mount'; 5 | import KoaLogger from 'koa-logger'; 6 | import bodyParser from 'koa-bodyparser'; 7 | import cors from '@koa/cors'; 8 | import { resolve } from 'path'; 9 | import createHandler from '@pokemon/handlers/create.handler'; 10 | import getHandler from '@pokemon/handlers/get.handler'; 11 | import getByIdHandler from '@pokemon/handlers/getbyid.handler'; 12 | import featuredHandler from '@pokemon/handlers/featured.handler'; 13 | import importHandler from '@pokemon/handlers/import.handler'; 14 | import removeHandler from '@pokemon/handlers/remove.handler'; 15 | import searchHandler from '@pokemon/handlers/search.handler'; 16 | import updateHandler from '@pokemon/handlers/update.handler'; 17 | import healthcheckHandler from '@pokemon/handlers/healthcheck.handler'; 18 | import { setupSequelize } from '@pokemon/utils/db'; 19 | import { instrumentRoute } from '@pokemon/middlewares/instrumentation'; 20 | import { graphqlHTTP } from 'koa-graphql'; 21 | import schema from './schema'; 22 | import resolvers from './graphql/resolvers'; 23 | 24 | const { APP_PORT = 8081 } = process.env; 25 | 26 | async function startApp() { 27 | const app = new Koa(); 28 | const ui = new Koa(); 29 | const router = new Router(); 30 | 31 | await setupSequelize(); 32 | 33 | const routeSetupFunctions = [ 34 | healthcheckHandler, // should be first than getByIdHandler since both paths could collide 35 | createHandler, 36 | getHandler, 37 | featuredHandler, 38 | searchHandler, 39 | getByIdHandler, 40 | importHandler, 41 | removeHandler, 42 | updateHandler, 43 | ]; 44 | 45 | for (const routeSetup of routeSetupFunctions) { 46 | routeSetup(router); 47 | } 48 | 49 | app 50 | .use(cors()) 51 | .use(instrumentRoute()) 52 | .use(bodyParser()) 53 | .use(KoaLogger()) 54 | .use(router.routes()) 55 | .use(router.allowedMethods()); 56 | 57 | ui.use(serve(resolve(__dirname, './ui'))); 58 | app.use(mount('/', ui)); 59 | app.use(mount('/graphql', graphqlHTTP({ schema, rootValue: resolvers, graphiql: true }))); 60 | 61 | console.log(`Starting server on port ${APP_PORT}`); 62 | app.listen(APP_PORT); 63 | } 64 | 65 | startApp(); 66 | -------------------------------------------------------------------------------- /api/src/constants/Tags.ts: -------------------------------------------------------------------------------- 1 | export enum CustomTags { 2 | HTTP_RESPONSE_BODY = 'http.response.body', 3 | RPC_RESPONSE_BODY = 'rpc.response.body', 4 | RPC_REQUEST_BODY = 'rpc.request.body', 5 | HTTP_REQUEST_BODY = 'http.request.body', 6 | VALIDATION_IS_VALID = 'validation.is_valid', 7 | VALIDATION_ERRORS = 'validation.errors', 8 | HTTP_REQUEST_HEADER = 'http.request.header', 9 | HTTP_RESPONSE_HEADER = 'http.response.header', 10 | DB_PAYLOAD = 'db.payload', 11 | DB_RESULT = 'db.result', 12 | MESSAGING_PAYLOAD = 'messaging.payload', 13 | MESSAGING_HEADER = 'messaging.header', 14 | CACHE_HIT = `cache.hit`, 15 | } 16 | -------------------------------------------------------------------------------- /api/src/graphql/create.resolver.ts: -------------------------------------------------------------------------------- 1 | import { getPokemonRepository, Pokemon } from '@pokemon/repositories'; 2 | 3 | const create = async (raw: Pokemon): Promise => { 4 | const repository = getPokemonRepository(); 5 | 6 | return repository.create(new Pokemon(raw)); 7 | }; 8 | 9 | export default create; 10 | -------------------------------------------------------------------------------- /api/src/graphql/get.resolver.ts: -------------------------------------------------------------------------------- 1 | import { getPokemonRepository, Pokemon } from '@pokemon/repositories'; 2 | import { SearchOptions } from '../repositories/pokemon.repository'; 3 | 4 | type PokemonList = { 5 | items: Pokemon[]; 6 | totalCount: number; 7 | }; 8 | 9 | const get = async (query: SearchOptions): Promise => { 10 | const repository = getPokemonRepository(); 11 | 12 | const [items, totalCount] = await Promise.all([repository.findMany(query), repository.count()]); 13 | 14 | return { 15 | items, 16 | totalCount, 17 | }; 18 | }; 19 | 20 | export default get; -------------------------------------------------------------------------------- /api/src/graphql/import.resolver.ts: -------------------------------------------------------------------------------- 1 | import PokeAPIService from '@pokemon/services/pokeApi.service'; 2 | import PokemonSyncronizer from '@pokemon/services/pokemonSyncronizer.service'; 3 | 4 | const pokeApiService = new PokeAPIService(); 5 | const pokemonSyncronizer = PokemonSyncronizer(pokeApiService); 6 | 7 | const importPokemon = async ({ id = 0, ignoreCache = false }) => { 8 | await pokemonSyncronizer.queue({ id, ignoreCache }); 9 | 10 | return { id }; 11 | }; 12 | 13 | export default importPokemon; 14 | -------------------------------------------------------------------------------- /api/src/graphql/resolvers.ts: -------------------------------------------------------------------------------- 1 | import createPokemon from './create.resolver'; 2 | import getPokemonList from './get.resolver'; 3 | import importPokemon from './import.resolver'; 4 | 5 | export default { 6 | getPokemonList, 7 | createPokemon, 8 | importPokemon, 9 | }; 10 | -------------------------------------------------------------------------------- /api/src/handlers/create.handler.ts: -------------------------------------------------------------------------------- 1 | import CreatePokemon from '@pokemon/validators/createPokemon'; 2 | import { validate } from '@pokemon/middlewares/validation'; 3 | import { jsonResponse } from '@pokemon/middlewares/response'; 4 | import { getPokemonRepository, Pokemon } from '@pokemon/repositories'; 5 | 6 | const create = async (ctx: { body: CreatePokemon }) => { 7 | const { name = '', type = '', isFeatured = false, imageUrl = '' } = ctx.body; 8 | const repository = getPokemonRepository(); 9 | 10 | const pokemon = await repository.create( 11 | new Pokemon({ 12 | name, 13 | type, 14 | isFeatured, 15 | imageUrl, 16 | }) 17 | ); 18 | 19 | return pokemon; 20 | }; 21 | 22 | export default function setupRoute(router) { 23 | router.post('/pokemon', jsonResponse(201), validate(CreatePokemon), create); 24 | } 25 | -------------------------------------------------------------------------------- /api/src/handlers/featured.handler.ts: -------------------------------------------------------------------------------- 1 | import { jsonResponse } from '@pokemon/middlewares/response'; 2 | import FeaturedService from '@pokemon/services/fetured.service'; 3 | const featuredService = FeaturedService(); 4 | 5 | const featured = async () => { 6 | const items = await featuredService.get(); 7 | 8 | return items; 9 | }; 10 | 11 | export default function setupRoute(router) { 12 | router.get('/pokemon/featured', jsonResponse(200), featured); 13 | } 14 | -------------------------------------------------------------------------------- /api/src/handlers/get.handler.ts: -------------------------------------------------------------------------------- 1 | import { jsonResponse } from '@pokemon/middlewares/response'; 2 | import { getPokemonRepository } from '@pokemon/repositories'; 3 | 4 | const get = async (ctx, next) => { 5 | const { skip = '0', take = '100' } = ctx.request.query || {}; 6 | const query = { skip: +skip, take: +take }; 7 | 8 | const repository = getPokemonRepository(); 9 | 10 | const [items, totalCount] = await Promise.all([repository.findMany(query), repository.count()]); 11 | 12 | return { 13 | totalCount, 14 | items, 15 | }; 16 | }; 17 | 18 | export default function setupRoute(router) { 19 | router.get('/pokemon', jsonResponse(200), get); 20 | } 21 | -------------------------------------------------------------------------------- /api/src/handlers/getbyid.handler.ts: -------------------------------------------------------------------------------- 1 | import { jsonResponse } from '@pokemon/middlewares/response'; 2 | import { getPokemonRepository } from '@pokemon/repositories'; 3 | import { getCacheService } from '@pokemon/services/cache.service'; 4 | 5 | const cache = getCacheService(); 6 | const repository = getPokemonRepository(); 7 | 8 | const getById = async (ctx, next) => { 9 | const { id = '0' } = ctx.params || {}; 10 | 11 | const cachedPokemon = await cache.get(`pokemon-${id}`); 12 | if (!!cachedPokemon) { 13 | return cachedPokemon; // cache hit 14 | } 15 | 16 | const databasePokemon = await repository.findOne(id); 17 | 18 | if (!!databasePokemon) { 19 | cache.set(`pokemon-${id}`, databasePokemon); 20 | } 21 | 22 | if (!databasePokemon) { 23 | ctx.status = 404; 24 | return; 25 | } 26 | 27 | return databasePokemon; 28 | }; 29 | 30 | export default function setupRoute(router) { 31 | router.get('/pokemon/:id', jsonResponse(200), getById); 32 | } 33 | -------------------------------------------------------------------------------- /api/src/handlers/healthcheck.handler.ts: -------------------------------------------------------------------------------- 1 | import { jsonResponse } from '@pokemon/middlewares/response'; 2 | import { getPokemonRepository } from '@pokemon/repositories'; 3 | import { getCacheService } from '@pokemon/services/cache.service'; 4 | import { MESSAGE_GROUP, TPokemonSyncMessage } from '@pokemon/services/pokemonSyncronizer.service'; 5 | import { createQueueService } from '@pokemon/services/queue.service'; 6 | 7 | const cache = getCacheService(); 8 | const queue = createQueueService(MESSAGE_GROUP); 9 | const repository = getPokemonRepository(); 10 | 11 | const isDatabaseAvailable = async () => { 12 | try { 13 | await repository.count(); 14 | return true; 15 | } catch (ex) { 16 | return false; 17 | } 18 | }; 19 | 20 | const healthcheck = async (ctx, next) => { 21 | const [cacheAvailable, databaseAvailable, queueAvailable] = await Promise.all([ 22 | cache.isAvailable(), 23 | isDatabaseAvailable(), 24 | queue.healthcheck(), 25 | ]); 26 | 27 | const requiredDependencies = [cacheAvailable, databaseAvailable, queueAvailable]; 28 | 29 | const response = { 30 | cache: cacheAvailable, 31 | database: databaseAvailable, 32 | queue: queueAvailable, 33 | }; 34 | 35 | if (requiredDependencies.some(item => !item)) { 36 | ctx.status = 500; 37 | } 38 | 39 | return response; 40 | }; 41 | 42 | export default function setupRoute(router) { 43 | router.get('/pokemon/healthcheck', jsonResponse(200), healthcheck); 44 | } 45 | -------------------------------------------------------------------------------- /api/src/handlers/import.handler.ts: -------------------------------------------------------------------------------- 1 | import ImportPokemon from '@pokemon/validators/importPokemon'; 2 | import PokeAPIService from '@pokemon/services/pokeApi.service'; 3 | import PokemonSyncronizer from '@pokemon/services/pokemonSyncronizer.service'; 4 | import { validate } from '@pokemon/middlewares/validation'; 5 | import { jsonResponse } from '@pokemon/middlewares/response'; 6 | 7 | const pokeApiService = new PokeAPIService(); 8 | const pokemonSyncronizer = PokemonSyncronizer(pokeApiService); 9 | 10 | const importPokemon = async (ctx: { body: ImportPokemon }) => { 11 | const { id = 0, ignoreCache = false } = ctx.body; 12 | 13 | await pokemonSyncronizer.queue({ 14 | id: id, 15 | ignoreCache: ignoreCache, 16 | }); 17 | 18 | return { 19 | id, 20 | }; 21 | }; 22 | 23 | export default function setupRoute(router) { 24 | router.post('/pokemon/import', jsonResponse(200), validate(ImportPokemon), importPokemon); 25 | } 26 | -------------------------------------------------------------------------------- /api/src/handlers/remove.handler.ts: -------------------------------------------------------------------------------- 1 | import { jsonResponse } from '@pokemon/middlewares/response'; 2 | import { getPokemonRepository } from '@pokemon/repositories'; 3 | import { getCacheService } from '@pokemon/services/cache.service'; 4 | 5 | const cache = getCacheService(); 6 | 7 | const remove = async ctx => { 8 | const { id = '0' } = ctx.params || {}; 9 | const repository = getPokemonRepository(); 10 | 11 | const pokemon = await repository.findOne(id); 12 | if (!pokemon) { 13 | ctx.status = 404; 14 | return; 15 | } 16 | 17 | cache.invalidate(`pokemon-${id}`) 18 | 19 | await repository.delete(+id); 20 | return pokemon; 21 | }; 22 | 23 | export default function setupRoute(router) { 24 | router.delete('/pokemon/:id', jsonResponse(), remove); 25 | } 26 | -------------------------------------------------------------------------------- /api/src/handlers/search.handler.ts: -------------------------------------------------------------------------------- 1 | import { jsonResponse } from '@pokemon/middlewares/response'; 2 | import { getPokemonRepository } from '@pokemon/repositories'; 3 | import { Op } from 'sequelize'; 4 | 5 | const search = async ctx => { 6 | const { skip = '0', take = '20', s = '' } = ctx.request.query || {}; 7 | const query = { skip: +skip, take: +take, where: { name: { [Op.iLike]: `%${s}%` } } }; 8 | 9 | const repository = getPokemonRepository(); 10 | 11 | const [items, totalCount] = await Promise.all([ 12 | repository.findMany(query), 13 | repository.count({ 14 | where: { name: { [Op.iLike]: `%${s}%` } }, 15 | }), 16 | ]); 17 | 18 | return { 19 | totalCount, 20 | items, 21 | }; 22 | }; 23 | 24 | export default function setupRoute(router) { 25 | router.get('/pokemon/search', jsonResponse(), search); 26 | } 27 | -------------------------------------------------------------------------------- /api/src/handlers/streamSyncronize.handler.ts: -------------------------------------------------------------------------------- 1 | import PokeAPIService from '@pokemon/services/pokeApi.service'; 2 | import PokemonSyncronizer, { TPokemonSyncMessage } from '@pokemon/services/pokemonSyncronizer.service'; 3 | import { StreamingService } from '@pokemon/services/stream.service'; 4 | import { KafkaMessage } from 'kafkajs' 5 | 6 | const pokemonSyncronizationHandler = async (message: KafkaMessage) => { 7 | const pokeApiService = new PokeAPIService(); 8 | const pokemonSyncronizer = PokemonSyncronizer(pokeApiService); 9 | 10 | const messageString = message.value?.toString() || ""; 11 | if (messageString === "") { 12 | return; 13 | } 14 | 15 | const msg: TPokemonSyncMessage = JSON.parse(messageString); 16 | await pokemonSyncronizer.sync(msg); 17 | }; 18 | 19 | export default function setupWorker(streamService: StreamingService) { 20 | streamService.subscribe(pokemonSyncronizationHandler); 21 | } 22 | -------------------------------------------------------------------------------- /api/src/handlers/syncronize.handler.ts: -------------------------------------------------------------------------------- 1 | import PokeAPIService from '@pokemon/services/pokeApi.service'; 2 | import PokemonSyncronizer, { TPokemonSyncMessage } from '@pokemon/services/pokemonSyncronizer.service'; 3 | import { QueueService } from '@pokemon/services/queue.service'; 4 | import ampqlib from 'amqplib'; 5 | 6 | const pokemonSyncronizationHandler = async (message: ampqlib.ConsumeMessage) => { 7 | const pokeApiService = new PokeAPIService(); 8 | const pokemonSyncronizer = PokemonSyncronizer(pokeApiService); 9 | const pokemonSyncMessage: TPokemonSyncMessage = JSON.parse(message.content.toString()); 10 | 11 | await pokemonSyncronizer.sync(pokemonSyncMessage); 12 | }; 13 | 14 | export default function setupWorker(queueService: QueueService) { 15 | queueService.subscribe(pokemonSyncronizationHandler); 16 | } 17 | -------------------------------------------------------------------------------- /api/src/handlers/update.handler.ts: -------------------------------------------------------------------------------- 1 | import UpdatePokemon from '@pokemon/validators/updatePokemon'; 2 | import { validate } from '@pokemon/middlewares/validation'; 3 | import { jsonResponse } from '@pokemon/middlewares/response'; 4 | import { getPokemonRepository, Pokemon } from '@pokemon/repositories'; 5 | 6 | const update = async (ctx: { status; body; params }) => { 7 | const { id = '0' } = ctx.params || {}; 8 | const repository = getPokemonRepository(); 9 | 10 | const pokemon = await repository.findOne(+id); 11 | if (!pokemon) { 12 | ctx.status = 404; 13 | return; 14 | } 15 | 16 | const updatedPokemon = await repository.update(+id, new Pokemon({ ...ctx.body })); 17 | 18 | return updatedPokemon; 19 | }; 20 | 21 | export default function setupRoute(router) { 22 | router.patch('/pokemon/:id', validate(UpdatePokemon), jsonResponse(), update); 23 | } 24 | -------------------------------------------------------------------------------- /api/src/middlewares/response.ts: -------------------------------------------------------------------------------- 1 | const jsonResponse = (statusCode: Number = 200) => { 2 | return async function jsonResponse(ctx, next) { 3 | try { 4 | ctx.status = statusCode; 5 | const response = await next(ctx); 6 | const status = ctx.status; 7 | ctx.body = response; 8 | // This is needed because if ctx.body is null or undefined, ctx.status is set to 204 9 | ctx.status = status; 10 | } catch (ex) { 11 | ctx.status = 500; 12 | ctx.body = {}; 13 | } 14 | }; 15 | }; 16 | 17 | export { jsonResponse }; 18 | -------------------------------------------------------------------------------- /api/src/middlewares/validation.ts: -------------------------------------------------------------------------------- 1 | import { SpanKind, SpanStatusCode } from '@opentelemetry/api'; 2 | import { createSpan, getParentSpan, runWithSpan } from '@pokemon/telemetry/tracing'; 3 | import { transformAndValidate } from 'class-transformer-validator'; 4 | import { CustomTags } from '../constants/Tags'; 5 | 6 | const validate = type => { 7 | return async function validate(ctx, next) { 8 | const parentSpan = await getParentSpan(); 9 | const span = await createSpan('validate request', parentSpan, { kind: SpanKind.INTERNAL }); 10 | span.addEvent("validation started") 11 | 12 | const body = ctx.request.body; 13 | try { 14 | const validType = await runWithSpan(span, async () => await transformAndValidate(type, body)); 15 | ctx.body = validType; 16 | span.setAttribute(CustomTags.VALIDATION_IS_VALID, true); 17 | span.addEvent("request is valid", {[CustomTags.VALIDATION_IS_VALID]: true}) 18 | return runWithSpan(span, async () => await next(ctx)); 19 | } catch (validationErrors) { 20 | ctx.status = 400; 21 | const response = mapErrorToResponse(validationErrors); 22 | 23 | span.setAttributes({ 24 | [CustomTags.VALIDATION_ERRORS]: JSON.stringify(response.errors), 25 | [CustomTags.VALIDATION_IS_VALID]: false, 26 | }); 27 | span.addEvent("request is invalid", { 28 | [CustomTags.VALIDATION_ERRORS]: JSON.stringify(response.errors), 29 | [CustomTags.VALIDATION_IS_VALID]: false, 30 | }) 31 | 32 | span.setStatus({ code: SpanStatusCode.ERROR }); 33 | ctx.body = response; 34 | return response; 35 | } finally { 36 | span.end(); 37 | } 38 | }; 39 | }; 40 | 41 | const mapErrorToResponse = errors => { 42 | const validationErrors = errors.map(error => { 43 | const errorMessages = Object.values(error.constraints); 44 | return { property: error.property, constraints: errorMessages }; 45 | }); 46 | 47 | return { 48 | errors: validationErrors, 49 | }; 50 | }; 51 | 52 | export { validate }; 53 | -------------------------------------------------------------------------------- /api/src/repositories/index.ts: -------------------------------------------------------------------------------- 1 | import { PokemonRepository, Pokemon } from '@pokemon/repositories/pokemon.repository'; 2 | import { InstrumentedPokemonRepository } from './instrumented.repository'; 3 | import { PokemonModel, SequelizePokemonRepository } from './pokemon.sequelize.repository'; 4 | 5 | function getPokemonRepository(): PokemonRepository { 6 | const realPokemonRepository = new SequelizePokemonRepository(); 7 | return new InstrumentedPokemonRepository(realPokemonRepository, PokemonModel); 8 | } 9 | 10 | export { Pokemon, getPokemonRepository, PokemonRepository }; 11 | -------------------------------------------------------------------------------- /api/src/repositories/pokemon.repository.ts: -------------------------------------------------------------------------------- 1 | export class Pokemon { 2 | public id?: number; 3 | public name: string; 4 | public type: string; 5 | public isFeatured: boolean; 6 | public imageUrl?: string; 7 | 8 | public constructor(data?: object | undefined) { 9 | if (data) { 10 | this.id = data['id']; 11 | this.imageUrl = data['imageUrl']; 12 | this.isFeatured = data['isFeatured']; 13 | this.type = data['type']; 14 | this.name = data['name']; 15 | } 16 | } 17 | } 18 | 19 | export type SearchOptions = { 20 | where?: object | undefined; 21 | skip?: number | undefined; 22 | take?: number | undefined; 23 | }; 24 | 25 | export interface PokemonRepository { 26 | create(pokemon: Pokemon): Promise; 27 | update(id: number, pokemon: Pokemon): Promise; 28 | delete(pokemonId: number): Promise; 29 | findOne(id: number): Promise; 30 | findMany(options?: SearchOptions | undefined): Promise; 31 | count(options?: SearchOptions | undefined): Promise; 32 | } 33 | -------------------------------------------------------------------------------- /api/src/repositories/pokemon.sequelize.repository.ts: -------------------------------------------------------------------------------- 1 | import { Column, PrimaryKey, Model, Table, AutoIncrement, AllowNull, Default } from 'sequelize-typescript'; 2 | import { Pokemon, PokemonRepository, SearchOptions } from '@pokemon/repositories/pokemon.repository'; 3 | 4 | @Table({ 5 | schema: 'public', 6 | tableName: 'pokemon', 7 | }) 8 | export class PokemonModel extends Model { 9 | @PrimaryKey 10 | @AutoIncrement 11 | @Column 12 | public id?: number; 13 | 14 | @Column 15 | public name: string; 16 | 17 | @Column 18 | public type: string; 19 | 20 | @AllowNull 21 | @Column 22 | public imageUrl?: string; 23 | 24 | @Default(false) 25 | @Column 26 | public isFeatured: boolean; 27 | } 28 | 29 | export class SequelizePokemonRepository implements PokemonRepository { 30 | async create(pokemon: Pokemon): Promise { 31 | const model = this.createModelFromPokemon(pokemon); 32 | await model.save(); 33 | 34 | return this.createPokemonFromModel(model); 35 | } 36 | 37 | async update(id: number, pokemon: Pokemon): Promise { 38 | const newData = { ...pokemon }; 39 | delete newData.id; 40 | await PokemonModel.update({ ...newData }, { where: { id: id } }); 41 | 42 | const model = await PokemonModel.findOne({ 43 | where: { id: id }, 44 | }); 45 | return this.createPokemonFromModel(model!!); 46 | } 47 | 48 | async delete(id: number): Promise { 49 | return await PokemonModel.destroy({ where: { id: id } }); 50 | } 51 | 52 | async findOne(id: number): Promise { 53 | const model = await PokemonModel.findOne({ where: { id: id } }); 54 | if (!model) { 55 | return null; 56 | } 57 | 58 | return this.createPokemonFromModel(model); 59 | } 60 | 61 | async findMany(options?: SearchOptions): Promise { 62 | const models = await PokemonModel.findAll({ 63 | where: { ...options?.where }, 64 | offset: options?.skip, 65 | limit: options?.take, 66 | }); 67 | 68 | return models.map(model => this.createPokemonFromModel(model)); 69 | } 70 | 71 | async count(options?: SearchOptions): Promise { 72 | return await PokemonModel.count({ 73 | where: { ...options?.where }, 74 | }); 75 | } 76 | 77 | private createModelFromPokemon(pokemon: Pokemon): PokemonModel { 78 | const model = new PokemonModel(); 79 | model.id = pokemon.id; 80 | model.name = pokemon.name; 81 | model.type = pokemon.type; 82 | model.isFeatured = pokemon.isFeatured; 83 | model.imageUrl = pokemon.imageUrl; 84 | 85 | return model; 86 | } 87 | 88 | private createPokemonFromModel(model: PokemonModel): Pokemon { 89 | return new Pokemon({ 90 | id: model.id, 91 | name: model.name, 92 | type: model.type, 93 | isFeatured: model.isFeatured, 94 | imageUrl: model.imageUrl, 95 | }); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /api/src/rpc.ts: -------------------------------------------------------------------------------- 1 | import { Server, ServerCredentials } from '@grpc/grpc-js'; 2 | import { PokeshopService } from './protos/pokeshop'; 3 | import PokemonRpcService from './services/pokemonRpc.service'; 4 | import { setupSequelize } from './utils/db'; 5 | 6 | const { RPC_PORT = 8082 } = process.env; 7 | const { RPC_HOST = `0.0.0.0:${RPC_PORT}` } = process.env 8 | 9 | const startApp = async () => { 10 | await setupSequelize(); 11 | 12 | const server = new Server(); 13 | 14 | server.addService(PokeshopService, PokemonRpcService); 15 | 16 | server.bindAsync(RPC_HOST, ServerCredentials.createInsecure(), error => { 17 | if (error) { 18 | console.log(error) 19 | } 20 | 21 | console.log(`Starting server on port ${RPC_PORT}`); 22 | server.start(); 23 | }); 24 | }; 25 | 26 | startApp(); 27 | -------------------------------------------------------------------------------- /api/src/schema.ts: -------------------------------------------------------------------------------- 1 | import { buildSchema } from 'graphql'; 2 | 3 | const schema = buildSchema(` 4 | type Pokemon { 5 | id: Int 6 | name: String! 7 | type: String! 8 | isFeatured: Boolean! 9 | imageUrl: String 10 | } 11 | 12 | type PokemonList { 13 | items: [Pokemon] 14 | totalCount: Int 15 | } 16 | 17 | type ImportPokemon { 18 | id: Int! 19 | } 20 | 21 | type Query { 22 | getPokemonList(where: String, skip: Int, take: Int): PokemonList 23 | } 24 | 25 | type Mutation { 26 | createPokemon(name: String!, type: String!, isFeatured: Boolean!, imageUrl: String): Pokemon! 27 | importPokemon(id: Int!): ImportPokemon! 28 | } 29 | `); 30 | 31 | export default schema; 32 | -------------------------------------------------------------------------------- /api/src/scripts/produceStreamMessage.ts: -------------------------------------------------------------------------------- 1 | import { Kafka } from 'kafkajs'; 2 | 3 | async function produceStreamMessage() { 4 | const kafka = new Kafka({ 5 | clientId: 'my-app', 6 | brokers: ['127.0.0.1:29092'], 7 | }) 8 | 9 | const producer = kafka.producer() 10 | 11 | await producer.connect() 12 | await producer.send( 13 | { 14 | topic: 'pokemon', 15 | messages: [ 16 | { value: '{\"id\":143}' }, 17 | ], 18 | }) 19 | 20 | await producer.disconnect() 21 | } 22 | 23 | produceStreamMessage().then(() => console.log("message published")) -------------------------------------------------------------------------------- /api/src/scripts/saveConfig.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { writeFileSync } from 'fs'; 3 | 4 | const PATH = '../ui/config.json'; 5 | const { SERVICE_NAME = '', HTTP_COLLECTOR_ENDPOINT = '' } = process.env; 6 | 7 | const saveConfig = () => { 8 | writeFileSync( 9 | resolve(__dirname, PATH), 10 | JSON.stringify({ 11 | HTTP_COLLECTOR_ENDPOINT, 12 | SERVICE_NAME, 13 | }) 14 | ); 15 | }; 16 | 17 | saveConfig(); 18 | -------------------------------------------------------------------------------- /api/src/services/fetured.service.ts: -------------------------------------------------------------------------------- 1 | import { getCacheService } from '@pokemon/services/cache.service'; 2 | import { getPokemonRepository, Pokemon } from '@pokemon/repositories'; 3 | 4 | const FeaturedService = () => { 5 | const cacheService = getCacheService(); 6 | const repository = getPokemonRepository(); 7 | const key = 'featured-list'; 8 | 9 | return { 10 | async get() { 11 | const fromCache = await cacheService.get(key); 12 | 13 | if (!!fromCache) return fromCache; 14 | 15 | const pokemons = await repository.findMany({ where: { isFeatured: true } }); 16 | await cacheService.set(key, pokemons); 17 | 18 | return pokemons; 19 | }, 20 | }; 21 | }; 22 | 23 | export default FeaturedService; 24 | -------------------------------------------------------------------------------- /api/src/services/pokeApi.service.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { snakeCase } from 'lodash'; 3 | import { getParentSpan, createSpan, runWithSpan } from '@pokemon/telemetry/tracing'; 4 | import { Span, SpanKind } from '@opentelemetry/api'; 5 | import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; 6 | import { CustomTags } from '../constants/Tags'; 7 | 8 | const { POKE_API_BASE_URL = '' } = process.env; 9 | 10 | type TRawPokemon = { 11 | name: string; 12 | types: Array<{ 13 | type: { 14 | name: string; 15 | }; 16 | }>; 17 | sprites: { 18 | front_default: string; 19 | }; 20 | }; 21 | 22 | export type TPokemon = { 23 | name: string; 24 | type: string; 25 | imageUrl: string; 26 | }; 27 | 28 | class PokeAPIService { 29 | private readonly baseRoute: string = '/pokemon'; 30 | private readonly baseUrl: string = `${POKE_API_BASE_URL}${this.baseRoute}`; 31 | 32 | async getPokemon(id: string): Promise { 33 | const parentSpan = await getParentSpan(); 34 | const span = await createSpan('GET', parentSpan, { kind: SpanKind.CLIENT }); 35 | 36 | try { 37 | return await this.getPokemonFromAPi(id, span); 38 | } finally { 39 | span.end(); 40 | } 41 | } 42 | 43 | private async getPokemonFromAPi(id: string, span: Span): Promise { 44 | return await runWithSpan(span, async () => { 45 | const {hostname, protocol, pathname} = new URL(`${this.baseUrl}/${id}`); 46 | 47 | span.setAttributes({ 48 | [SemanticAttributes.HTTP_URL]: `${this.baseUrl}/${id}`, 49 | [SemanticAttributes.HTTP_METHOD]: 'GET', 50 | [SemanticAttributes.HTTP_ROUTE]: pathname, 51 | [SemanticAttributes.HTTP_SCHEME]: protocol, 52 | [SemanticAttributes.NET_PEER_NAME]: hostname, 53 | }); 54 | 55 | const response = await fetch(`${this.baseUrl}/${id}`, { 56 | method: 'GET', 57 | }); 58 | 59 | const pokemon = (await response.json()) as TRawPokemon; 60 | 61 | span.setAttributes({ 62 | [SemanticAttributes.HTTP_STATUS_CODE]: response.status, 63 | [SemanticAttributes.HTTP_RESPONSE_CONTENT_LENGTH]: JSON.stringify(pokemon).length, 64 | [CustomTags.HTTP_RESPONSE_BODY]: JSON.stringify({ name: pokemon.name }), 65 | }); 66 | 67 | Object.entries(response.headers).forEach(([key, value]) => { 68 | span.setAttribute(`${CustomTags.HTTP_RESPONSE_HEADER}.${snakeCase(key)}`, JSON.stringify([value])); 69 | }); 70 | 71 | const { name, types, sprites } = pokemon; 72 | 73 | return { 74 | name, 75 | type: types.map(({ type }) => type.name).join(','), 76 | imageUrl: sprites.front_default, 77 | }; 78 | }); 79 | } 80 | } 81 | 82 | export default PokeAPIService; 83 | -------------------------------------------------------------------------------- /api/src/services/pokemonRpc.service.ts: -------------------------------------------------------------------------------- 1 | import { instrumentRpcServer } from '../middlewares/instrumentation'; 2 | import { PokeshopServer } from '../protos/pokeshop'; 3 | import { getPokemonRepository, Pokemon } from '../repositories'; 4 | import PokeAPIService from './pokeApi.service'; 5 | import PokemonSyncronizer from './pokemonSyncronizer.service'; 6 | 7 | const pokemonSyncronizer = PokemonSyncronizer(PokeAPIService); 8 | const repository = getPokemonRepository(); 9 | 10 | const PokemonRpcService = (): PokeshopServer => ({ 11 | async getPokemonList({ request: { take = 20, skip = 0 } }, callback) { 12 | const [items, totalCount] = await Promise.all([repository.findMany({ take, skip }), repository.count()]); 13 | 14 | callback(null, { items, totalCount }); 15 | }, 16 | async createPokemon({ request: { name, type, isFeatured, imageUrl } }, callback) { 17 | const pokemon = await repository.create( 18 | new Pokemon({ 19 | name, 20 | type, 21 | isFeatured, 22 | imageUrl, 23 | }) 24 | ); 25 | 26 | callback(null, pokemon); 27 | }, 28 | async importPokemon({ request: { id, ignoreCache }}, callback) { 29 | await pokemonSyncronizer.queue({ 30 | id, 31 | ignoreCache: ignoreCache ?? false, 32 | }); 33 | 34 | callback(null, { id }); 35 | }, 36 | }); 37 | 38 | export default instrumentRpcServer(PokemonRpcService(), 'Pokeshop'); 39 | -------------------------------------------------------------------------------- /api/src/services/pokemonSyncronizer.service.ts: -------------------------------------------------------------------------------- 1 | import { SpanKind } from '@opentelemetry/api'; 2 | import { getPokemonRepository, Pokemon } from '@pokemon/repositories'; 3 | import { createQueueService } from '@pokemon/services/queue.service'; 4 | import { createSpan, getParentSpan, runWithSpan } from '@pokemon/telemetry/tracing'; 5 | import { getCacheService } from './cache.service'; 6 | import { TPokemon } from './pokeApi.service'; 7 | 8 | export const MESSAGE_GROUP = 'queue.synchronizePokemon'; 9 | 10 | export type TPokemonSyncMessage = { 11 | id: number; 12 | ignoreCache: boolean; 13 | }; 14 | 15 | const PokemonSyncronizer = pokeApiService => { 16 | const queue = createQueueService(MESSAGE_GROUP); 17 | const repository = getPokemonRepository(); 18 | const cache = getCacheService(); 19 | 20 | async function getFromCache(message: TPokemonSyncMessage) { 21 | if (message.ignoreCache) { 22 | return null 23 | } 24 | 25 | return await cache.get(`pokemon_${message.id}`) 26 | } 27 | 28 | return { 29 | async queue(message: TPokemonSyncMessage) { 30 | return queue.send(message); 31 | }, 32 | 33 | async sync(message: TPokemonSyncMessage) { 34 | const parentSpan = await getParentSpan(); 35 | const span = await createSpan('import pokemon', parentSpan, { kind: SpanKind.INTERNAL }); 36 | 37 | try { 38 | return await runWithSpan(span, async () => { 39 | let pokemon = await getFromCache(message) 40 | if (!pokemon) { 41 | pokemon = await pokeApiService.getPokemon(message.id); 42 | await cache.set(`pokemon_${message.id}`, pokemon!!) 43 | } 44 | 45 | await repository.create(new Pokemon({ ...pokemon })); 46 | }); 47 | } catch (ex) { 48 | console.log(ex); 49 | } finally { 50 | span.end(); 51 | } 52 | }, 53 | }; 54 | }; 55 | 56 | export default PokemonSyncronizer; 57 | -------------------------------------------------------------------------------- /api/src/streamWorker.ts: -------------------------------------------------------------------------------- 1 | import syncronizeHandler from '@pokemon/handlers/streamSyncronize.handler'; 2 | import { createStreamingService } from '@pokemon/services/stream.service'; 3 | import { TPokemonSyncMessage } from '@pokemon/services/pokemonSyncronizer.service'; 4 | import { setupSequelize } from '@pokemon/utils/db'; 5 | 6 | async function startWorker() { 7 | await setupSequelize(); 8 | const pokemonSyncronizationStreamingService = createStreamingService(); 9 | syncronizeHandler(pokemonSyncronizationStreamingService); 10 | } 11 | 12 | startWorker(); 13 | -------------------------------------------------------------------------------- /api/src/telemetry/instrumented.component.ts: -------------------------------------------------------------------------------- 1 | import { Span, SpanKind, SpanStatusCode } from '@opentelemetry/api'; 2 | import { createSpan, getParentSpan, runWithSpan } from '@pokemon/telemetry/tracing'; 3 | 4 | export abstract class InstrumentedComponent { 5 | protected async instrumentMethod( 6 | spanName: string, 7 | spanKind: SpanKind | undefined, 8 | innerMethod: (span?: Span) => Promise 9 | ): Promise { 10 | const parentSpan = await getParentSpan(); 11 | const span = await createSpan(spanName, parentSpan, { kind: spanKind || SpanKind.INTERNAL }); 12 | try { 13 | return await runWithSpan(span, () => innerMethod(span)); 14 | } catch (ex) { 15 | span.recordException(ex); 16 | span.setStatus({ code: SpanStatusCode.ERROR }); 17 | throw ex; 18 | } finally { 19 | span.end(); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /api/src/telemetry/setup.ts: -------------------------------------------------------------------------------- 1 | import { getTracer } from '@pokemon/telemetry/tracing'; 2 | 3 | getTracer(); 4 | -------------------------------------------------------------------------------- /api/src/telemetry/tracing.ts: -------------------------------------------------------------------------------- 1 | import * as opentelemetry from '@opentelemetry/api'; 2 | import { NodeSDK } from '@opentelemetry/sdk-node'; 3 | import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc'; 4 | import * as dotenv from 'dotenv'; 5 | import { SpanStatusCode } from '@opentelemetry/api'; 6 | import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api'; 7 | import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; 8 | 9 | diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG); 10 | 11 | // Make sure all env variables are available in process.env 12 | dotenv.config(); 13 | 14 | const { COLLECTOR_ENDPOINT = '', SERVICE_NAME = 'pokeshop' } = process.env; 15 | 16 | let globalTracer: opentelemetry.Tracer | null = null; 17 | 18 | async function createTracer(): Promise { 19 | const collectorExporter = new OTLPTraceExporter({ 20 | url: COLLECTOR_ENDPOINT, 21 | }); 22 | 23 | const sdk = new NodeSDK({ 24 | serviceName: SERVICE_NAME, 25 | spanProcessors: [ 26 | new BatchSpanProcessor(collectorExporter, { 27 | maxQueueSize: 10000, 28 | scheduledDelayMillis: 200, 29 | }), 30 | ], 31 | instrumentations: [], 32 | }); 33 | 34 | await sdk.start(); 35 | process.on('SIGTERM', () => { 36 | sdk 37 | .shutdown() 38 | .then( 39 | () => console.log('SDK shut down successfully'), 40 | err => console.log('Error shutting down SDK', err) 41 | ) 42 | .finally(() => process.exit(0)); 43 | }); 44 | 45 | const tracer = opentelemetry.trace.getTracer(SERVICE_NAME); 46 | 47 | globalTracer = tracer; 48 | 49 | return globalTracer; 50 | } 51 | 52 | async function getTracer(): Promise { 53 | if (globalTracer) { 54 | return globalTracer; 55 | } 56 | 57 | return createTracer(); 58 | } 59 | 60 | async function getParentSpan(): Promise { 61 | const parentSpan = opentelemetry.trace.getSpan(opentelemetry.context.active()); 62 | if (!parentSpan) { 63 | return undefined; 64 | } 65 | 66 | return parentSpan; 67 | } 68 | 69 | async function createSpan( 70 | name: string, 71 | parentSpan?: opentelemetry.Span | undefined, 72 | options?: opentelemetry.SpanOptions | undefined 73 | ): Promise { 74 | const tracer = await getTracer(); 75 | if (parentSpan) { 76 | const context = opentelemetry.trace.setSpan(opentelemetry.context.active(), parentSpan); 77 | 78 | return createSpanFromContext(name, context, options); 79 | } 80 | 81 | return tracer.startSpan(name); 82 | } 83 | 84 | async function createSpanFromContext( 85 | name: string, 86 | ctx: opentelemetry.Context, 87 | options?: opentelemetry.SpanOptions | undefined 88 | ): Promise { 89 | const tracer = await getTracer(); 90 | if (!ctx) { 91 | return tracer.startSpan(name, options, opentelemetry.context.active()); 92 | } 93 | 94 | return tracer.startSpan(name, options, ctx); 95 | } 96 | 97 | async function runWithSpan(parentSpan: opentelemetry.Span, fn: () => Promise): Promise { 98 | const ctx = opentelemetry.trace.setSpan(opentelemetry.context.active(), parentSpan); 99 | 100 | try { 101 | return await opentelemetry.context.with(ctx, fn); 102 | } catch (ex) { 103 | parentSpan.recordException(ex); 104 | parentSpan.setStatus({ code: SpanStatusCode.ERROR }); 105 | throw ex; 106 | } 107 | } 108 | 109 | export { getTracer, getParentSpan, createSpan, createSpanFromContext, runWithSpan }; 110 | -------------------------------------------------------------------------------- /api/src/utils/db.ts: -------------------------------------------------------------------------------- 1 | import { PokemonModel } from '@pokemon/repositories/pokemon.sequelize.repository'; 2 | import { Sequelize } from 'sequelize-typescript'; 3 | import { SequelizeStorage, Umzug } from 'umzug'; 4 | 5 | export let sequelize: Sequelize | undefined = undefined; 6 | 7 | export async function setupSequelize() { 8 | const { DATABASE_URL = '' } = process.env; 9 | 10 | sequelize = new Sequelize(DATABASE_URL, { 11 | dialect: 'postgres', 12 | }); 13 | 14 | sequelize.addModels([PokemonModel]); 15 | 16 | await runMigrations(sequelize); 17 | } 18 | 19 | async function runMigrations(sequelize) { 20 | const umzug = new Umzug({ 21 | migrations: { glob: 'migrations/*.js' }, 22 | context: sequelize.getQueryInterface(), 23 | storage: new SequelizeStorage({ sequelize }), 24 | logger: console, 25 | }); 26 | 27 | await umzug.up(); 28 | } 29 | -------------------------------------------------------------------------------- /api/src/validators/createPokemon.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsString } from 'class-validator'; 2 | 3 | class CreatePokemon { 4 | @IsString() 5 | public name: number; 6 | 7 | @IsString() 8 | public type: string; 9 | 10 | @IsBoolean() 11 | public isFeatured: boolean; 12 | 13 | @IsString() 14 | public imageUrl: string; 15 | } 16 | 17 | export default CreatePokemon; 18 | -------------------------------------------------------------------------------- /api/src/validators/importPokemon.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsPositive } from 'class-validator'; 2 | 3 | class ImportPokemon { 4 | @IsNumber() 5 | @IsPositive() 6 | public id: number; 7 | 8 | public ignoreCache: boolean; 9 | } 10 | 11 | export default ImportPokemon; 12 | -------------------------------------------------------------------------------- /api/src/validators/updatePokemon.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsOptional, IsString } from 'class-validator'; 2 | 3 | class UpdatePokemon { 4 | @IsString() 5 | @IsOptional() 6 | public name: string; 7 | 8 | @IsString() 9 | @IsOptional() 10 | public type: string; 11 | 12 | @IsBoolean() 13 | @IsOptional() 14 | public isFeatured: boolean; 15 | } 16 | 17 | export default UpdatePokemon; 18 | -------------------------------------------------------------------------------- /api/src/worker.ts: -------------------------------------------------------------------------------- 1 | import syncronizeHandler from '@pokemon/handlers/syncronize.handler'; 2 | import { createQueueService } from '@pokemon/services/queue.service'; 3 | import { MESSAGE_GROUP, TPokemonSyncMessage } from '@pokemon/services/pokemonSyncronizer.service'; 4 | import { setupSequelize } from '@pokemon/utils/db'; 5 | 6 | async function startWorker() { 7 | await setupSequelize(); 8 | const pokemonSyncronizationQueueService = createQueueService(MESSAGE_GROUP); 9 | syncronizeHandler(pokemonSyncronizationQueueService); 10 | } 11 | 12 | startWorker(); 13 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strictPropertyInitialization": false, 4 | "esModuleInterop": true, 5 | "experimentalDecorators": true, 6 | "emitDecoratorMetadata": true, 7 | "preserveConstEnums": true, 8 | "strictNullChecks": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "allowJs": true, 12 | "target": "es6", 13 | "module": "CommonJS", 14 | "outDir": ".build/", 15 | "moduleResolution": "node", 16 | "lib": ["es6", "es7"], 17 | "rootDir": "./", 18 | "baseUrl": ".", 19 | "paths": { 20 | "@pokemon/*": [ 21 | "src/*" 22 | ] 23 | } 24 | }, 25 | "include": ["./src/**/*"], 26 | "exclude": ["node_modules"], 27 | "types": ["node", "aws-sdk"] 28 | } 29 | -------------------------------------------------------------------------------- /collector.config.yaml: -------------------------------------------------------------------------------- 1 | receivers: 2 | otlp: 3 | protocols: 4 | grpc: 5 | http: 6 | cors: 7 | allowed_origins: 8 | - "http://*" 9 | - "https://*" 10 | 11 | processors: 12 | batch: 13 | 14 | filter: 15 | error_mode: ignore 16 | traces: 17 | span: 18 | - 'trace_state["tracetest"] != "true"' 19 | 20 | exporters: 21 | logging: 22 | loglevel: debug 23 | otlp: 24 | endpoint: jaeger:4317 25 | tls: 26 | insecure: true 27 | otlp/trace: 28 | endpoint: tracetest-agent:4317 29 | tls: 30 | insecure: true 31 | 32 | service: 33 | pipelines: 34 | traces: 35 | receivers: [otlp] 36 | processors: [filter, batch] 37 | exporters: [otlp] 38 | traces/agent: 39 | receivers: [otlp] 40 | processors: [batch] 41 | exporters: [otlp/trace] 42 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | import { config } from 'dotenv'; 3 | 4 | config(); 5 | 6 | module.exports = defineConfig({ 7 | chromeWebSecurity: false, 8 | e2e: { 9 | baseUrl: process.env.POKESHOP_DEMO_URL || 'http://localhost:3000', 10 | env: { 11 | TRACETEST_API_TOKEN: process.env.TRACETEST_API_TOKEN, 12 | TRACETEST_SERVER_URL: process.env.TRACETEST_SERVER_URL || 'https://app.tracetest.io', 13 | TRACETEST_ENVIRONMENT_ID: process.env.TRACETEST_ENVIRONMENT_ID 14 | }, 15 | setupNodeEvents() { 16 | // implement node event listeners here 17 | }, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /cypress/e2e/1-getting-started/home.cy.ts: -------------------------------------------------------------------------------- 1 | import Tracetest, { Types } from '@tracetest/cypress'; 2 | 3 | const TRACETEST_API_TOKEN = Cypress.env('TRACETEST_API_TOKEN') || ''; 4 | const TRACETEST_ENVIRONMENT_ID = Cypress.env('TRACETEST_ENVIRONMENT_ID') || ''; 5 | const TRACETEST_SERVER_URL = Cypress.env('TRACETEST_SERVER_URL') || 'https://app.tracetest.io'; 6 | 7 | let tracetest: Types.TracetestCypress | undefined = undefined; 8 | 9 | const definition = ` 10 | type: Test 11 | spec: 12 | id: aW1wb3J0cyBhIHBva2Vtb23= 13 | name: "Cypress: imports a pokemon" 14 | trigger: 15 | type: cypress 16 | specs: 17 | - selector: span[tracetest.span.type="http"] span[tracetest.span.type="http"] 18 | name: "All HTTP Spans: Status code is 200" 19 | assertions: 20 | - attr:http.status_code = 200 21 | - selector: span[tracetest.span.type="database"] 22 | name: "All Database Spans: Processing time is less than 100ms" 23 | assertions: 24 | - attr:tracetest.span.duration < 2s 25 | outputs: 26 | - name: MY_OUTPUT 27 | selector: span[tracetest.span.type="general" name="Tracetest trigger"] 28 | value: attr:name 29 | `; 30 | 31 | describe('Home', { defaultCommandTimeout: 80000 }, () => { 32 | before(done => { 33 | Tracetest({ 34 | apiToken: TRACETEST_API_TOKEN, 35 | environmentId: TRACETEST_ENVIRONMENT_ID, 36 | serverUrl: TRACETEST_SERVER_URL, 37 | serverPath: '' 38 | }).then(instance => { 39 | tracetest = instance; 40 | tracetest 41 | .setOptions({ 42 | 'Cypress: imports a pokemon': { 43 | definition, 44 | }, 45 | }) 46 | .then(() => done()); 47 | }); 48 | }); 49 | 50 | beforeEach(() => { 51 | tracetest.capture(); 52 | cy.visit('/'); 53 | }); 54 | 55 | // uncomment to wait for trace tests to be done 56 | after(done => { 57 | tracetest.summary().then(() => done()); 58 | }); 59 | 60 | it('Cypress: create a pokemon', () => { 61 | cy.get('[data-cy="create-pokemon-button"]').should('be.visible').click(); 62 | cy.get('[data-cy="create-pokemon-modal"]').should('be.visible'); 63 | cy.get('#name').type('Pikachu'); 64 | cy.get('#type').type('Electric'); 65 | cy.get('#imageUrl').type('https://oyster.ignimgs.com/mediawiki/apis.ign.com/pokemon-blue-version/8/89/Pikachu.jpg'); 66 | 67 | cy.get('button').contains('OK').click(); 68 | }); 69 | 70 | it('Cypress: imports a pokemon', () => { 71 | cy.get('[data-cy="import-pokemon-button"]').click(); 72 | cy.get('[data-cy="import-pokemon-form"]').should('be.visible'); 73 | 74 | cy.get('[id="id"]') 75 | .last() 76 | .type(Math.floor(Math.random() * 101).toString()); 77 | cy.get('button').contains('OK').click({ force: true }); 78 | }); 79 | 80 | it('Cypress: deletes a pokemon', () => { 81 | cy.get('[data-cy="pokemon-list"]').should('be.visible'); 82 | cy.get('[data-cy="pokemon-card"]').first().click().get('[data-cy="delete-pokemon-button"]').first().click(); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /docker-compose.e2e.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | name: pokeshop 3 | 4 | services: 5 | tracetest-agent: 6 | image: kubeshop/tracetest-agent:latest 7 | environment: 8 | TRACETEST_DEV: ${TRACETEST_DEV} 9 | TRACETEST_API_KEY: ${TRACETEST_AGENT_API_KEY} 10 | TRACETEST_SERVER_URL: ${TRACETEST_SERVER_URL} 11 | TRACETEST_ENVIRONMENT_ID: ${TRACETEST_ENVIRONMENT_ID} 12 | TRACETEST_TRACE_MODE: ${TRACETEST_TRACE_MODE} 13 | 14 | -------------------------------------------------------------------------------- /docker-compose.k6.workflows.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | k6-tracetest: 5 | build: 6 | context: . 7 | dockerfile: k6.Dockerfile 8 | environment: 9 | XK6_TRACETEST_API_TOKEN: ${TRACETEST_API_TOKEN} 10 | XK6_TRACETEST_SERVER_URL: ${TRACETEST_SERVER_URL} 11 | POKESHOP_DEMO_URL: ${POKESHOP_DEMO_URL} 12 | volumes: 13 | - ./test/k6/add-pokemon.js:/import-pokemon.js 14 | -------------------------------------------------------------------------------- /docker-compose.k6.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | k6-tracetest: 5 | build: 6 | context: . 7 | dockerfile: k6.Dockerfile 8 | environment: 9 | XK6_TRACETEST_API_TOKEN: ${TRACETEST_API_TOKEN} 10 | POKESHOP_DEMO_URL: ${POKESHOP_DEMO_URL} 11 | depends_on: 12 | - api 13 | - tracetest-agent 14 | - worker 15 | volumes: 16 | - ./test/k6/import-pokemon.js:/import-pokemon.js 17 | tracetest-agent: 18 | image: kubeshop/tracetest-agent:latest 19 | environment: 20 | TRACETEST_DEV: ${TRACETEST_DEV} 21 | TRACETEST_API_KEY: ${TRACETEST_AGENT_API_KEY} 22 | TRACETEST_SERVER_URL: ${TRACETEST_SERVER_URL} 23 | -------------------------------------------------------------------------------- /docker-compose.stream.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | name: pokeshop 3 | 4 | services: 5 | stream: 6 | image: confluentinc/cp-kafka:latest-ubi8 7 | ports: 8 | - 29092:29092 9 | environment: 10 | - KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://stream:9092,PLAINTEXT_HOST://127.0.0.1:29092 11 | - KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,PLAINTEXT_HOST://:29092 12 | - KAFKA_CONTROLLER_QUORUM_VOTERS=1@0.0.0.0:9093 13 | - KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER 14 | - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT 15 | - KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS=0 16 | - KAFKA_PROCESS_ROLES=controller,broker 17 | - KAFKA_NODE_ID=1 18 | - KAFKA_METADATA_LOG_SEGMENT_MS=15000 19 | - KAFKA_METADATA_MAX_RETENTION_MS=60000 20 | - KAFKA_METADATA_LOG_MAX_RECORD_BYTES_BETWEEN_SNAPSHOTS=2800 21 | - KAFKA_AUTO_CREATE_TOPICS_ENABLE=true 22 | - KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 23 | - KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 24 | - KAFKA_HEAP_OPTS=-Xmx200m -Xms200m 25 | - CLUSTER_ID=ckjPoprWQzOf0-FuNkGfFQ 26 | healthcheck: 27 | test: nc -z stream 9092 28 | start_period: 10s 29 | interval: 5s 30 | timeout: 10s 31 | retries: 10 32 | 33 | streaming-worker: 34 | build: . 35 | environment: 36 | DATABASE_URL: postgresql://ashketchum:squirtle123@db:5432/pokeshop?schema=public 37 | POKE_API_BASE_URL: https://pokeapi.co/api/v2 38 | COLLECTOR_ENDPOINT: http://otel-collector:4317 39 | ZIPKIN_URL: http://localhost:9411 40 | NPM_RUN_COMMAND: stream-worker 41 | KAFKA_BROKER: 'stream:9092' 42 | KAFKA_TOPIC: 'pokemon' 43 | KAFKA_CLIENT_ID: 'streaming-worker' 44 | REDIS_URL: cache 45 | SERVICE_NAME: pokeshop-streaming-worker 46 | restart: on-failure 47 | depends_on: 48 | db: 49 | condition: service_healthy 50 | stream: 51 | condition: service_healthy 52 | jaeger: 53 | condition: service_healthy 54 | cache: 55 | condition: service_healthy 56 | otel-collector: 57 | condition: service_started 58 | -------------------------------------------------------------------------------- /docs/diagrams/api-create-pokemon.mdd: -------------------------------------------------------------------------------- 1 | sequenceDiagram 2 | participant API as POST /pokemon 3 | participant Validator as Validation middleware 4 | participant Handler as API Handler 5 | participant DB as Postgres 6 | 7 | API->>Validator: request 8 | alt request is invalid 9 | Validator-->>API: 400 Bad Request
10 | end 11 | Validator->>Handler: valid request 12 | Handler->>DB: save pokemon 13 | DB-->>Handler: saved pokemon 14 | Handler-->>Validator: pokemon object 15 | Validator-->>API: 200 OK
-------------------------------------------------------------------------------- /docs/diagrams/api-create-pokemon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kubeshop/pokeshop/3be5812abce5efaedf426c29b11da5b0b5b3ea64/docs/diagrams/api-create-pokemon.png -------------------------------------------------------------------------------- /docs/diagrams/api-get-pokemon.mdd: -------------------------------------------------------------------------------- 1 | sequenceDiagram 2 | participant API as /GET pokemon 3 | participant Handler as API Handler 4 | participant DB as Postgres 5 | API->>Handler: request 6 | Handler-->>DB: get list of pokemons 7 | DB-->>Handler: list of pokemons 8 | Handler-->>API: 200 OK -------------------------------------------------------------------------------- /docs/diagrams/api-get-pokemon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kubeshop/pokeshop/3be5812abce5efaedf426c29b11da5b0b5b3ea64/docs/diagrams/api-get-pokemon.png -------------------------------------------------------------------------------- /docs/diagrams/api-import-pokemon.mdd: -------------------------------------------------------------------------------- 1 | sequenceDiagram 2 | participant API as POST /pokemon/import 3 | participant Validator as Validation middleware 4 | participant Handler as API Handler 5 | participant Queue 6 | 7 | API->>Validator: request 8 | alt request is invalid 9 | Validator-->>API: 400 Bad request
10 | end 11 | Validator->>Handler: valid request 12 | Handler->>Queue: enqueue "import" task 13 | Queue-->>Handler: ok 14 | Handler-->>Validator: ok 15 | Validator-->>API: ok -------------------------------------------------------------------------------- /docs/diagrams/api-import-pokemon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kubeshop/pokeshop/3be5812abce5efaedf426c29b11da5b0b5b3ea64/docs/diagrams/api-import-pokemon.png -------------------------------------------------------------------------------- /docs/diagrams/worker-import-pokemon.mdd: -------------------------------------------------------------------------------- 1 | sequenceDiagram 2 | participant Queue 3 | participant Worker 4 | participant PokeAPI 5 | participant DB as Postgres 6 | 7 | loop every new message 8 | Queue->>Worker: import pokemon 9 | end 10 | Worker->>PokeAPI: get pokemon info 11 | PokeAPI-->>Worker: pokemon info 12 | Worker->>DB: save pokemon 13 | DB-->>Worker: ok 14 | alt if succesful 15 | Worker-->>Queue: ack 16 | else if failed 17 | Worker-->>Queue: nack 18 | end -------------------------------------------------------------------------------- /docs/diagrams/worker-import-pokemon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kubeshop/pokeshop/3be5812abce5efaedf426c29b11da5b0b5b3ea64/docs/diagrams/worker-import-pokemon.png -------------------------------------------------------------------------------- /docs/installing.md: -------------------------------------------------------------------------------- 1 | # Installing 2 | 3 | ## Run it locally 4 | 5 | To try it locally, you can use Docker Compose to run the API, its Worker, and all its dependencies. 6 | 7 | ### Requirements 8 | 9 | 1. [Docker](https://www.docker.com/get-started/) 10 | 2. [Docker Compose](https://docs.docker.com/compose/install/) 11 | 12 | ### Steps 13 | 14 | 1. Clone the project 15 | 2. Go to the `pokeshop` project folder 16 | 3. Execute `make run` 17 | 18 | ### Script 19 | 20 | ```$ 21 | git clone git@github.com:kubeshop/pokeshop.git 22 | cd pokeshop 23 | make run 24 | ``` 25 | 26 | This will start the Pokeshop app on `http://localhost:8081` and Tracetest on `http://localhost:11633`. 27 | 28 | The `make run` command will also trigger four **Tests** when started. 29 | 30 | ![tracetest initial tests](https://res.cloudinary.com/djwdcmwdz/image/upload/v1693846733/docs/localhost_11633__10_px4kqa.png) 31 | 32 | As well as one **Test Suite**. 33 | 34 | ![tracetest initial test suite](https://res.cloudinary.com/djwdcmwdz/image/upload/v1693846736/docs/localhost_11633__11_coms2i.png) 35 | 36 | 37 | ## Run on a Kubernetes cluster 38 | 39 | If you want to run this project on a real cluster, we provide a helm chart to install it. This installation doesn't create a jaeger instance for you, so you have to install it manually and set the `JAEGER_HOST` and `JAEGER_PORT` on the `env` section of the file `helm-chart/values.yml`. 40 | 41 | ### Requirements 42 | 43 | 1. [helm](https://helm.sh/) 44 | 45 | ### Steps 46 | 47 | 1. Clone the project 48 | 2. Go to the helm-chart folder inside the project folder 49 | 3. Execute `helm dependency update` 50 | 4. Update `JAEGER_HOST` and `JAEGER_PORT` on the file `helm-chart/values.yml` to reflect your cluster's jaeger instance 51 | 5. Execute `helm install -n demo -f values.yaml --create-namespace demo .` 52 | 53 | > :warning: **This will create a namespace called "demo" on your cluster**. If you wish to change it, replace `-n demo` on step 5 with `-n `. 54 | -------------------------------------------------------------------------------- /helm-chart/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /helm-chart/Chart.lock: -------------------------------------------------------------------------------- 1 | dependencies: 2 | - name: postgresql 3 | repository: https://charts.bitnami.com/bitnami 4 | version: 12.1.6 5 | - name: rabbitmq 6 | repository: https://charts.bitnami.com/bitnami 7 | version: 11.2.2 8 | - name: redis 9 | repository: https://charts.bitnami.com/bitnami 10 | version: 17.3.17 11 | - name: opentelemetry-collector 12 | repository: https://open-telemetry.github.io/opentelemetry-helm-charts 13 | version: 0.92.0 14 | - name: jaeger 15 | repository: https://jaegertracing.github.io/helm-charts 16 | version: 3.0.10 17 | digest: sha256:3004524c4ef3f6cb0d3b04bd4f652b239ff0f0ffb73b39ae96c89f71d7221591 18 | generated: "2024-08-28T14:20:10.713465+02:00" 19 | -------------------------------------------------------------------------------- /helm-chart/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: pokemon-api 3 | description: A Helm chart for Kubernetes 4 | 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | 15 | # This is the chart version. This version number should be incremented each time you make changes 16 | # to the chart and its templates, including the app version. 17 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 18 | version: 0.1.0 19 | 20 | # This is the version number of the application being deployed. This version number should be 21 | # incremented each time you make changes to the application. Versions are not expected to 22 | # follow Semantic Versioning. They should reflect the version the application is using. 23 | # It is recommended to use it with quotes. 24 | appVersion: "0.0.15" 25 | 26 | dependencies: 27 | - name: postgresql 28 | version: 12.1.6 29 | repository: https://charts.bitnami.com/bitnami 30 | - name: rabbitmq 31 | version: 11.2.2 32 | repository: https://charts.bitnami.com/bitnami 33 | - name: redis 34 | version: 17.3.17 35 | repository: https://charts.bitnami.com/bitnami 36 | - name: opentelemetry-collector 37 | version: 0.92.0 38 | repository: https://open-telemetry.github.io/opentelemetry-helm-charts 39 | - name: jaeger 40 | version: 3.0.10 41 | repository: https://jaegertracing.github.io/helm-charts 42 | -------------------------------------------------------------------------------- /helm-chart/readme.md: -------------------------------------------------------------------------------- 1 | # Install Pokeshop with Helm 2 | 3 | 1. Update Helm dependencies 4 | 5 | ```bash 6 | helm dependency update 7 | ``` 8 | 9 | 2. Install the Helm chart 10 | 11 | ```bash 12 | helm install -f ./values.yaml pokeshop . 13 | ``` 14 | 15 | 3. [Get your Tracetest API key and env id](https://app.tracetest.io/retrieve-token) 16 | 4. Install Tracetest Agent 17 | 18 | ```bash 19 | helm repo add tracetestcloud https://kubeshop.github.io/tracetest-cloud-charts --force-update && helm install agent tracetestcloud/tracetest-agent --set agent.apiKey= --set agent.environmentId= 20 | ``` 21 | 22 | 5. Create and run a test by going to [`app.tracetest.io`](https://app.tracetest.io) and using the internal Kubernetes service networking: 23 | 24 | - **POST** `http://pokeshop-pokemon-api:8081/pokemon/import` - Body: `{ "id": 1 }` 25 | - **GET** `http://pokeshop-pokemon-api:8081/pokemon` 26 | 27 | ![](https://res.cloudinary.com/djwdcmwdz/image/upload/v1725358889/docs/app.tracetest.io_organizations_ttorg_e66318ba6544b856_environments_ttenv_4b0e8945dbe5045a_test_Q6Mr5o3Ig_run_24_trigger_agj1ls.png) 28 | -------------------------------------------------------------------------------- /helm-chart/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "pokemon-api.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "pokemon-api.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "pokemon-api.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "pokemon-api.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") 20 | echo "Visit http://127.0.0.1:8080 to use your application" 21 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT 22 | {{- end }} 23 | -------------------------------------------------------------------------------- /helm-chart/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "pokemon-api.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "pokemon-api.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "pokemon-api.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "pokemon-api.labels" -}} 37 | helm.sh/chart: {{ include "pokemon-api.chart" . }} 38 | {{ include "pokemon-api.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "pokemon-api.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "pokemon-api.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Selector labels (stream) 55 | */}} 56 | {{- define "pokemon-api.selectorLabelsStream" -}} 57 | app.kubernetes.io/name: {{ include "pokemon-api.name" . }}-stream 58 | app.kubernetes.io/instance: {{ .Release.Name }} 59 | {{- end }} 60 | 61 | {{/* 62 | Create the name of the service account to use 63 | */}} 64 | {{- define "pokemon-api.serviceAccountName" -}} 65 | {{- if .Values.serviceAccount.create }} 66 | {{- default (include "pokemon-api.fullname" .) .Values.serviceAccount.name }} 67 | {{- else }} 68 | {{- default "default" .Values.serviceAccount.name }} 69 | {{- end }} 70 | {{- end }} 71 | -------------------------------------------------------------------------------- /helm-chart/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2beta1 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "pokemon-api.fullname" . }} 6 | labels: 7 | {{- include "pokemon-api.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "pokemon-api.fullname" . }} 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 21 | {{- end }} 22 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 23 | - type: Resource 24 | resource: 25 | name: memory 26 | targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 27 | {{- end }} 28 | {{- end }} 29 | -------------------------------------------------------------------------------- /helm-chart/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "pokemon-api.fullname" . -}} 3 | {{- $svcPort := .Values.service.port -}} 4 | {{- $rpcPort := .Values.service.rpcPort -}} 5 | {{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} 6 | {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} 7 | {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} 8 | {{- end }} 9 | {{- end }} 10 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 11 | apiVersion: networking.k8s.io/v1 12 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 13 | apiVersion: networking.k8s.io/v1beta1 14 | {{- else -}} 15 | apiVersion: extensions/v1beta1 16 | {{- end }} 17 | kind: Ingress 18 | metadata: 19 | name: {{ $fullName }} 20 | labels: 21 | {{- include "pokemon-api.labels" . | nindent 4 }} 22 | {{- with .Values.ingress.annotations }} 23 | annotations: 24 | {{- toYaml . | nindent 4 }} 25 | {{- end }} 26 | spec: 27 | {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} 28 | ingressClassName: {{ .Values.ingress.className }} 29 | {{- end }} 30 | {{- if .Values.ingress.tls }} 31 | tls: 32 | {{- range .Values.ingress.tls }} 33 | - hosts: 34 | {{- range .hosts }} 35 | - {{ . | quote }} 36 | {{- end }} 37 | secretName: {{ .secretName }} 38 | {{- end }} 39 | {{- end }} 40 | rules: 41 | {{- range .Values.ingress.hosts }} 42 | - host: {{ .host | quote }} 43 | http: 44 | paths: 45 | {{- range .paths }} 46 | - path: {{ .path }} 47 | {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 48 | pathType: {{ .pathType }} 49 | {{- end }} 50 | backend: 51 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 52 | service: 53 | name: {{ $fullName }} 54 | port: 55 | number: {{ $svcPort }} 56 | {{- else }} 57 | serviceName: {{ $fullName }} 58 | servicePort: {{ $svcPort }} 59 | {{- end }} 60 | - path: {{ .path }} 61 | {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 62 | pathType: {{ .pathType }} 63 | {{- end }} 64 | backend: 65 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 66 | service: 67 | name: {{ $fullName }} 68 | port: 69 | number: {{ $rpcPort }} 70 | {{- else }} 71 | serviceName: {{ $fullName }} 72 | servicePort: {{ $rpcPort }} 73 | {{- end }} 74 | {{- end }} 75 | {{- end }} 76 | {{- end }} 77 | -------------------------------------------------------------------------------- /helm-chart/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "pokemon-api.fullname" . }} 5 | labels: 6 | {{- include "pokemon-api.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: 80 12 | protocol: TCP 13 | name: ingress 14 | - port: {{ .Values.service.httpPort }} 15 | targetPort: 8081 16 | protocol: TCP 17 | name: http 18 | - port: {{ .Values.service.rpcPort }} 19 | targetPort: 8082 20 | protocol: TCP 21 | name: grpc 22 | selector: 23 | {{- include "pokemon-api.selectorLabels" . | nindent 4 }} 24 | -------------------------------------------------------------------------------- /helm-chart/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "pokemon-api.serviceAccountName" . }} 6 | labels: 7 | {{- include "pokemon-api.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /helm-chart/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "pokemon-api.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "pokemon-api.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "pokemon-api.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /k6.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21-alpine as builder 2 | 3 | WORKDIR /app 4 | 5 | ENV CGO_ENABLED 0 6 | RUN go install go.k6.io/xk6/cmd/xk6@latest 7 | 8 | RUN xk6 build v0.50.0 --with github.com/kubeshop/xk6-tracetest 9 | 10 | FROM alpine 11 | 12 | COPY --from=builder /app/k6 /bin/ 13 | COPY ./test/k6/import-pokemon.js . 14 | ENV XK6_TRACETEST_API_TOKEN=your-api-token 15 | ENV POKESHOP_DEMO_URL=http://localhost:8081 16 | ENV K6_TEARDOWN_TIMEOUT=600s 17 | 18 | ENTRYPOINT k6 run /import-pokemon.js -o xk6-tracetest -e POKESHOP_DEMO_URL=$POKESHOP_DEMO_URL 19 | -------------------------------------------------------------------------------- /k8s/api.yaml: -------------------------------------------------------------------------------- 1 | # Deployment 2 | --- 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | labels: 7 | io.kompose.service: api 8 | name: api 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | io.kompose.service: api 14 | template: 15 | metadata: 16 | annotations: 17 | kompose.cmd: kompose convert -f docker-compose.yaml 18 | kompose.version: 1.34.0 (HEAD) 19 | labels: 20 | io.kompose.service: api 21 | spec: 22 | containers: 23 | - env: 24 | - name: COLLECTOR_ENDPOINT 25 | value: http://otel-collector.default.svc.cluster.local:4317 26 | - name: DATABASE_URL 27 | value: postgresql://ashketchum:squirtle123@db:5432/pokeshop?schema=public 28 | - name: POKE_API_BASE_URL 29 | value: https://pokeapi.co/api/v2 30 | - name: RABBITMQ_HOST 31 | value: guest:guest@queue 32 | - name: REDIS_URL 33 | value: cache 34 | image: kubeshop/demo-pokemon-api:latest 35 | imagePullPolicy: Always 36 | livenessProbe: 37 | exec: 38 | command: 39 | - wget 40 | - --spider 41 | - localhost:8081/pokemon/healthcheck 42 | failureThreshold: 60 43 | periodSeconds: 1 44 | timeoutSeconds: 3 45 | name: api 46 | ports: 47 | - containerPort: 8081 48 | protocol: TCP 49 | restartPolicy: Always 50 | 51 | # Service 52 | --- 53 | apiVersion: v1 54 | kind: Service 55 | metadata: 56 | labels: 57 | io.kompose.service: api 58 | name: api 59 | spec: 60 | ports: 61 | - name: "8081" 62 | port: 8081 63 | targetPort: 8081 64 | selector: 65 | io.kompose.service: api 66 | -------------------------------------------------------------------------------- /k8s/cache.yaml: -------------------------------------------------------------------------------- 1 | # Deployment 2 | --- 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | labels: 7 | io.kompose.service: cache 8 | name: cache 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | io.kompose.service: cache 14 | template: 15 | metadata: 16 | labels: 17 | io.kompose.service: cache 18 | spec: 19 | containers: 20 | - image: redis:6 21 | livenessProbe: 22 | exec: 23 | command: 24 | - redis-cli 25 | - ping 26 | failureThreshold: 60 27 | periodSeconds: 1 28 | timeoutSeconds: 3 29 | name: cache 30 | ports: 31 | - containerPort: 6379 32 | protocol: TCP 33 | restartPolicy: Always 34 | 35 | # Service 36 | --- 37 | apiVersion: v1 38 | kind: Service 39 | metadata: 40 | labels: 41 | io.kompose.service: cache 42 | name: cache 43 | spec: 44 | ports: 45 | - name: "6379" 46 | port: 6379 47 | targetPort: 6379 48 | selector: 49 | io.kompose.service: cache 50 | -------------------------------------------------------------------------------- /k8s/db.yaml: -------------------------------------------------------------------------------- 1 | # Deployment 2 | --- 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | labels: 7 | io.kompose.service: db 8 | name: db 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | io.kompose.service: db 14 | template: 15 | metadata: 16 | labels: 17 | io.kompose.service: db 18 | spec: 19 | containers: 20 | - env: 21 | - name: POSTGRES_DB 22 | value: pokeshop 23 | - name: POSTGRES_PASSWORD 24 | value: squirtle123 25 | - name: POSTGRES_USER 26 | value: ashketchum 27 | image: postgres:14 28 | name: db 29 | ports: 30 | - containerPort: 5432 31 | protocol: TCP 32 | volumeMounts: 33 | - mountPath: /var/lib/postgresql/data 34 | name: dbdata 35 | volumes: 36 | - name: dbdata 37 | persistentVolumeClaim: 38 | claimName: db-volume-claim 39 | restartPolicy: Always 40 | 41 | # Service 42 | --- 43 | apiVersion: v1 44 | kind: Service 45 | metadata: 46 | labels: 47 | io.kompose.service: db 48 | name: db 49 | spec: 50 | ports: 51 | - name: "5432" 52 | port: 5432 53 | targetPort: 5432 54 | selector: 55 | io.kompose.service: db 56 | 57 | # Persistent Volume 58 | --- 59 | apiVersion: v1 60 | kind: PersistentVolume 61 | metadata: 62 | name: db-volume 63 | labels: 64 | type: local 65 | io.kompose.service: db 66 | spec: 67 | storageClassName: manual 68 | capacity: 69 | storage: 10Gi 70 | accessModes: 71 | - ReadWriteMany 72 | hostPath: 73 | path: /data/postgresql 74 | 75 | # Persistent Volume Claim 76 | --- 77 | apiVersion: v1 78 | kind: PersistentVolumeClaim 79 | metadata: 80 | name: db-volume-claim 81 | labels: 82 | io.kompose.service: db 83 | spec: 84 | storageClassName: manual 85 | accessModes: 86 | - ReadWriteMany 87 | resources: 88 | requests: 89 | storage: 10Gi 90 | -------------------------------------------------------------------------------- /k8s/jaeger.yaml: -------------------------------------------------------------------------------- 1 | # Deployment 2 | --- 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | labels: 7 | io.kompose.service: jaeger 8 | name: jaeger 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | io.kompose.service: jaeger 14 | template: 15 | metadata: 16 | labels: 17 | io.kompose.service: jaeger 18 | spec: 19 | containers: 20 | - env: 21 | - name: COLLECTOR_OTLP_ENABLED 22 | value: "true" 23 | - name: COLLECTOR_ZIPKIN_HOST_PORT 24 | value: :9411 25 | image: jaegertracing/all-in-one:latest 26 | livenessProbe: 27 | exec: 28 | command: 29 | - wget 30 | - --spider 31 | - localhost:16686 32 | failureThreshold: 60 33 | periodSeconds: 1 34 | timeoutSeconds: 3 35 | name: jaeger 36 | ports: 37 | - containerPort: 4317 38 | protocol: TCP 39 | - containerPort: 14250 40 | protocol: TCP 41 | - containerPort: 16685 42 | protocol: TCP 43 | - containerPort: 16686 44 | protocol: TCP 45 | restartPolicy: Always 46 | 47 | # Service 48 | --- 49 | apiVersion: v1 50 | kind: Service 51 | metadata: 52 | labels: 53 | io.kompose.service: jaeger 54 | name: jaeger 55 | spec: 56 | ports: 57 | - name: "4317" 58 | port: 4317 59 | targetPort: 4317 60 | - name: "14250" 61 | port: 14250 62 | targetPort: 14250 63 | - name: "16685" 64 | port: 16685 65 | targetPort: 16685 66 | - name: "16686" 67 | port: 16686 68 | targetPort: 16686 69 | selector: 70 | io.kompose.service: jaeger 71 | -------------------------------------------------------------------------------- /k8s/otel-collector.yaml: -------------------------------------------------------------------------------- 1 | # Deployment 2 | --- 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | labels: 7 | io.kompose.service: otel-collector 8 | name: otel-collector 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | io.kompose.service: otel-collector 14 | strategy: 15 | type: Recreate 16 | template: 17 | metadata: 18 | labels: 19 | io.kompose.service: otel-collector 20 | spec: 21 | containers: 22 | - args: 23 | - --config 24 | - /config/otel-local-config.yaml 25 | env: 26 | - name: JAEGER_ENDPOINT 27 | value: jaeger.default.svc.cluster.local:4317 28 | - name: TRACETEST_AGENT_ENDPOINT 29 | value: tracetest-agent.default.svc.cluster.local:4317 30 | image: otel/opentelemetry-collector-contrib:0.107.0 31 | name: otel-collector 32 | ports: 33 | - containerPort: 55679 34 | protocol: TCP 35 | - containerPort: 8888 36 | protocol: TCP 37 | - containerPort: 4317 38 | protocol: TCP 39 | - containerPort: 4318 40 | protocol: TCP 41 | volumeMounts: 42 | - mountPath: /config 43 | name: otel-collector-cm0 44 | restartPolicy: Always 45 | volumes: 46 | - configMap: 47 | items: 48 | - key: collector.config.yaml 49 | path: otel-local-config.yaml 50 | name: otel-collector-cm0 51 | name: otel-collector-cm0 52 | 53 | # Service 54 | --- 55 | apiVersion: v1 56 | kind: Service 57 | metadata: 58 | labels: 59 | io.kompose.service: otel-collector 60 | name: otel-collector 61 | spec: 62 | ports: 63 | - name: "55679" 64 | port: 55679 65 | targetPort: 55679 66 | - name: "8888" 67 | port: 8888 68 | targetPort: 8888 69 | - name: "4317" 70 | port: 4317 71 | targetPort: 4317 72 | - name: "4318" 73 | port: 4318 74 | targetPort: 4318 75 | selector: 76 | io.kompose.service: otel-collector 77 | 78 | # ConfigMap 79 | --- 80 | apiVersion: v1 81 | data: 82 | collector.config.yaml: | 83 | receivers: 84 | otlp: 85 | protocols: 86 | grpc: 87 | endpoint: 0.0.0.0:4317 88 | http: 89 | endpoint: 0.0.0.0:4318 90 | processors: 91 | batch: 92 | exporters: 93 | logging: 94 | loglevel: debug 95 | otlp/jaeger: 96 | endpoint: ${JAEGER_ENDPOINT} 97 | tls: 98 | insecure: true 99 | otlp/tracetest: 100 | endpoint: ${TRACETEST_AGENT_ENDPOINT} 101 | tls: 102 | insecure: true 103 | service: 104 | pipelines: 105 | traces/jaeger: 106 | receivers: [otlp] 107 | processors: [] 108 | exporters: [logging, otlp/jaeger] 109 | traces/tracetest: 110 | receivers: [otlp] 111 | processors: [batch] 112 | exporters: [otlp/tracetest] 113 | kind: ConfigMap 114 | metadata: 115 | annotations: 116 | use-subpath: "true" 117 | labels: 118 | io.kompose.service: otel-collector 119 | name: otel-collector-cm0 120 | -------------------------------------------------------------------------------- /k8s/queue.yaml: -------------------------------------------------------------------------------- 1 | # Deployment 2 | --- 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | labels: 7 | io.kompose.service: queue 8 | name: queue 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | io.kompose.service: queue 14 | template: 15 | metadata: 16 | labels: 17 | io.kompose.service: queue 18 | spec: 19 | containers: 20 | - image: rabbitmq:3.12 21 | livenessProbe: 22 | exec: 23 | command: 24 | - rabbitmq-diagnostics -q check_running 25 | failureThreshold: 60 26 | periodSeconds: 1 27 | timeoutSeconds: 5 28 | name: queue 29 | ports: 30 | - containerPort: 5672 31 | protocol: TCP 32 | - containerPort: 15672 33 | protocol: TCP 34 | restartPolicy: Always 35 | 36 | # Service 37 | --- 38 | apiVersion: v1 39 | kind: Service 40 | metadata: 41 | labels: 42 | io.kompose.service: queue 43 | name: queue 44 | spec: 45 | ports: 46 | - name: "5672" 47 | port: 5672 48 | targetPort: 5672 49 | - name: "15672" 50 | port: 15672 51 | targetPort: 15672 52 | selector: 53 | io.kompose.service: queue 54 | -------------------------------------------------------------------------------- /k8s/readme.md: -------------------------------------------------------------------------------- 1 | # Install Pokeshop with K8s Manifests 2 | 3 | 1. [Get your Tracetest API key and env id](https://app.tracetest.io/retrieve-token) 4 | 2. Add your API key and env id in the `tracetest-agent.yaml` 5 | 3. Apply all resources 6 | 7 | ```bash 8 | kubectl apply -f . 9 | ``` 10 | 11 | 4. Create and run a test by going to [`app.tracetest.io`](https://app.tracetest.io) and using the internal Kubernetes service networking: 12 | 13 | - **POST** `http://api.default.svc.cluster.local:8081/pokemon/import` - Body: `{ "id": 1 }` 14 | - **GET** `http://api.default.svc.cluster.local:8081/pokemon` 15 | 16 | ![](https://res.cloudinary.com/djwdcmwdz/image/upload/v1724764008/docs/app.tracetest.io_organizations_ttorg_e66318ba6544b856_environments_ttenv_4b0e8945dbe5045a_test_tTFZ453Ig_run_9_selectedSpan_bb8ba205b42a8619_nylqid.png) 17 | 18 | 5. View the trace and create test specs by going to the `Test` tab. 19 | 20 | ![](https://res.cloudinary.com/djwdcmwdz/image/upload/v1724764098/docs/app.tracetest.io_organizations_ttorg_e66318ba6544b856_environments_ttenv_4b0e8945dbe5045a_test_tTFZ453Ig_run_9_selectedSpan_bb8ba205b42a8619_1_xaxlbi.png) 21 | -------------------------------------------------------------------------------- /k8s/rpc.yaml: -------------------------------------------------------------------------------- 1 | # Deployment 2 | --- 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | labels: 7 | io.kompose.service: rpc 8 | name: rpc 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | io.kompose.service: rpc 14 | template: 15 | metadata: 16 | labels: 17 | io.kompose.service: rpc 18 | spec: 19 | containers: 20 | - env: 21 | - name: NPM_RUN_COMMAND 22 | value: rpc 23 | - name: COLLECTOR_ENDPOINT 24 | value: http://otel-collector.default.svc.cluster.local:4317 25 | - name: DATABASE_URL 26 | value: postgresql://ashketchum:squirtle123@db:5432/pokeshop?schema=public 27 | - name: POKE_API_BASE_URL 28 | value: https://pokeapi.co/api/v2 29 | - name: RABBITMQ_HOST 30 | value: guest:guest@queue 31 | - name: REDIS_URL 32 | value: cache 33 | - name: RPC_PORT 34 | value: "8082" 35 | image: kubeshop/demo-pokemon-api:latest 36 | imagePullPolicy: Always 37 | livenessProbe: 38 | exec: 39 | command: 40 | - wget 41 | - --spider 42 | - 0.0.0.0:8081/pokemon/healthcheck 43 | failureThreshold: 60 44 | periodSeconds: 1 45 | timeoutSeconds: 3 46 | name: rpc 47 | ports: 48 | - containerPort: 8082 49 | protocol: TCP 50 | restartPolicy: Always 51 | 52 | # Service 53 | --- 54 | apiVersion: v1 55 | kind: Service 56 | metadata: 57 | labels: 58 | io.kompose.service: rpc 59 | name: rpc 60 | spec: 61 | ports: 62 | - name: "8082" 63 | port: 8082 64 | targetPort: 8082 65 | selector: 66 | io.kompose.service: rpc 67 | -------------------------------------------------------------------------------- /k8s/tracetest-agent.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Service 3 | apiVersion: v1 4 | kind: Service 5 | metadata: 6 | name: tracetest-agent 7 | labels: 8 | app.kubernetes.io/name: tracetest-agent 9 | app.kubernetes.io/instance: tracetest-agent 10 | spec: 11 | selector: 12 | app.kubernetes.io/name: tracetest-agent 13 | app.kubernetes.io/instance: tracetest-agent 14 | ports: 15 | - name: grpc-collector-entrypoint 16 | protocol: TCP 17 | port: 4317 18 | targetPort: 4317 19 | - name: http-collector-entrypoint 20 | protocol: TCP 21 | port: 4318 22 | targetPort: 4318 23 | --- 24 | # Deployment 25 | apiVersion: apps/v1 26 | kind: Deployment 27 | metadata: 28 | name: tracetest-agent 29 | labels: 30 | app: tracetest-agent 31 | app.kubernetes.io/name: tracetest-agent 32 | app.kubernetes.io/instance: tracetest-agent 33 | spec: 34 | selector: 35 | matchLabels: 36 | app.kubernetes.io/name: tracetest-agent 37 | app.kubernetes.io/instance: tracetest-agent 38 | template: 39 | metadata: 40 | labels: 41 | app.kubernetes.io/name: tracetest-agent 42 | app.kubernetes.io/instance: tracetest-agent 43 | spec: 44 | containers: 45 | - name: agent 46 | image: "kubeshop/tracetest-agent:latest" 47 | imagePullPolicy: Always 48 | args: [ 49 | "--environment", 50 | "", # Add your env id 51 | "--api-key", 52 | "$TRACETEST_API_KEY", 53 | "--server-url", 54 | "https://app.tracetest.io", 55 | "--mode='verbose'", 56 | ] 57 | env: 58 | - name: TRACETEST_API_KEY 59 | value: "" # Add your API key 60 | -------------------------------------------------------------------------------- /k8s/worker.yaml: -------------------------------------------------------------------------------- 1 | # Deployment 2 | --- 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | labels: 7 | io.kompose.service: worker 8 | name: worker 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | io.kompose.service: worker 14 | template: 15 | metadata: 16 | labels: 17 | io.kompose.service: worker 18 | spec: 19 | containers: 20 | - env: 21 | - name: NPM_RUN_COMMAND 22 | value: worker 23 | - name: COLLECTOR_ENDPOINT 24 | value: http://otel-collector.default.svc.cluster.local:4317 25 | - name: DATABASE_URL 26 | value: postgresql://ashketchum:squirtle123@db:5432/pokeshop?schema=public 27 | - name: POKE_API_BASE_URL 28 | value: https://pokeapi.co/api/v2 29 | - name: RABBITMQ_HOST 30 | value: guest:guest@queue 31 | - name: REDIS_URL 32 | value: cache 33 | image: kubeshop/demo-pokemon-api:latest 34 | imagePullPolicy: Always 35 | name: worker 36 | restartPolicy: Always 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pokeshop", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "cy:open": "cypress open", 8 | "cy:run": "cypress run", 9 | "pw:run": "playwright test", 10 | "pw:open": "playwright test --ui", 11 | "artillery:run": "artillery run ./test/artillery/import-pokemon.yml", 12 | "k6:run": "sh ./test/k6/run.sh", 13 | "test": "npm run cy:run && npm run pw:run && npm run artillery:run && npm run k6:run", 14 | "generate-diagram": "mmdc" 15 | }, 16 | "author": "", 17 | "license": "ISC", 18 | "devDependencies": { 19 | "@mermaid-js/mermaid-cli": "^10.6.1", 20 | "@playwright/test": "1.45.0", 21 | "@types/node": "^20.10.6", 22 | "@typescript-eslint/eslint-plugin": "^6.14.0", 23 | "@typescript-eslint/parser": "^6.14.0", 24 | "artillery": "^2.0.9-83ceb0d", 25 | "cypress": "^13.6.1", 26 | "dotenv": "^16.3.1", 27 | "eslint": "^8.55.0", 28 | "eslint-config-airbnb-base": "^15.0.0", 29 | "eslint-import-resolver-alias": "^1.1.2", 30 | "eslint-plugin-import": "^2.29.0", 31 | "eslint-plugin-jest": "^27.6.0", 32 | "eslint-plugin-module-resolver": "^1.5.0", 33 | "typescript": "^5.3.3" 34 | }, 35 | "dependencies": { 36 | "@opentelemetry/sdk-trace-base": "^1.26.0", 37 | "@tracetest/cypress": "^0.2.0", 38 | "@tracetest/playwright": "0.2.0", 39 | "artillery-plugin-tracetest": "^0.2.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | import { config } from 'dotenv'; 3 | 4 | config(); 5 | 6 | /** 7 | * Read environment variables from file. 8 | * https://github.com/motdotla/dotenv 9 | */ 10 | // require('dotenv').config(); 11 | 12 | /** 13 | * See https://playwright.dev/docs/test-configuration. 14 | */ 15 | export default defineConfig({ 16 | testDir: './playwright', 17 | /* Run tests in files in parallel */ 18 | fullyParallel: true, 19 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 20 | forbidOnly: !!process.env.CI, 21 | /* Retry on CI only */ 22 | retries: process.env.CI ? 2 : 0, 23 | /* Opt out of parallel tests on CI. */ 24 | workers: process.env.CI ? 1 : undefined, 25 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 26 | reporter: 'html', 27 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 28 | use: { 29 | /* Base URL to use in actions like `await page.goto('/')`. */ 30 | baseURL: process.env.POKESHOP_DEMO_URL || 'http://localhost:3000', 31 | 32 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 33 | trace: 'on-first-retry', 34 | }, 35 | 36 | /* Configure projects for major browsers */ 37 | projects: [ 38 | { 39 | name: 'chromium', 40 | use: { 41 | ...devices['Desktop Chrome'], 42 | launchOptions: { 43 | args: ['--disable-web-security'], 44 | }, 45 | }, 46 | }, 47 | 48 | // { 49 | // name: 'firefox', 50 | // use: { ...devices['Desktop Firefox'] }, 51 | // }, 52 | 53 | // { 54 | // name: 'webkit', 55 | // use: { ...devices['Desktop Safari'] }, 56 | // }, 57 | 58 | /* Test against mobile viewports. */ 59 | // { 60 | // name: 'Mobile Chrome', 61 | // use: { ...devices['Pixel 5'] }, 62 | // }, 63 | // { 64 | // name: 'Mobile Safari', 65 | // use: { ...devices['iPhone 12'] }, 66 | // }, 67 | 68 | /* Test against branded browsers. */ 69 | // { 70 | // name: 'Microsoft Edge', 71 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 72 | // }, 73 | // { 74 | // name: 'Google Chrome', 75 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 76 | // }, 77 | ], 78 | 79 | /* Run your local dev server before starting the tests */ 80 | // webServer: { 81 | // command: 'npm run start', 82 | // url: 'http://127.0.0.1:3000', 83 | // reuseExistingServer: !process.env.CI, 84 | // }, 85 | }); 86 | -------------------------------------------------------------------------------- /playwright/home.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import Tracetest, { Types } from '@tracetest/playwright'; 3 | 4 | const { TRACETEST_API_TOKEN = '', TRACETEST_SERVER_URL = 'https://app.tracetest.io', TRACETEST_ENVIRONMENT_ID = '' } = process.env; 5 | 6 | let tracetest: Types.TracetestPlaywright | undefined = undefined; 7 | 8 | test.describe.configure({ mode: 'serial' }); 9 | 10 | const definition = ` 11 | type: Test 12 | spec: 13 | id: UGxheXdyaWdodDogaW1wb3J0cyBhIHBva2Vtb24= 14 | name: "Playwright: imports a pokemon" 15 | trigger: 16 | type: playwright 17 | specs: 18 | - selector: span[tracetest.span.type="http"] 19 | name: "All HTTP Spans: Status code is 200" 20 | assertions: 21 | - attr:http.status_code = 200 22 | - selector: span[tracetest.span.type="database"] 23 | name: "All Database Spans: Processing time is less than 100ms" 24 | assertions: 25 | - attr:tracetest.span.duration < 2s 26 | outputs: 27 | - name: MY_OUTPUT 28 | selector: span[tracetest.span.type="general" name="Tracetest trigger"] 29 | value: attr:name 30 | `; 31 | 32 | test.beforeAll(async () => { 33 | tracetest = await Tracetest({ 34 | apiToken: TRACETEST_API_TOKEN, 35 | serverUrl: TRACETEST_SERVER_URL, 36 | serverPath: '', 37 | environmentId: TRACETEST_ENVIRONMENT_ID, 38 | }); 39 | 40 | await tracetest.setOptions({ 41 | 'Playwright: imports a pokemon': { 42 | definition, 43 | }, 44 | }); 45 | }); 46 | 47 | test.beforeEach(async ({ page, context }, info) => { 48 | await tracetest?.capture({ context, info }); 49 | await page.goto('/'); 50 | }); 51 | 52 | // optional step to break the playwright script in case a Tracetest test fails 53 | test.afterAll(async ({}, testInfo) => { 54 | testInfo.setTimeout(80000); 55 | await tracetest?.summary(); 56 | }); 57 | 58 | test('Playwright: creates a pokemon', async ({ page }) => { 59 | expect(await page.getByText('Pokeshop')).toBeTruthy(); 60 | 61 | await page.click('text=Add'); 62 | 63 | await page.getByLabel('Name').fill('Charizard'); 64 | await page.getByLabel('Type').fill('Flying'); 65 | await page 66 | .getByLabel('Image URL') 67 | .fill('https://upload.wikimedia.org/wikipedia/en/1/1f/Pok%C3%A9mon_Charizard_art.png'); 68 | await page.getByRole('button', { name: 'OK', exact: true }).click(); 69 | }); 70 | 71 | test('Playwright: imports a pokemon', async ({ page }) => { 72 | expect(await page.getByText('Pokeshop')).toBeTruthy(); 73 | 74 | await page.click('text=Import'); 75 | 76 | await page.getByLabel('ID').fill('143'); 77 | 78 | await Promise.all([ 79 | page.waitForResponse(resp => resp.url().includes('/pokemon/import') && resp.status() === 200), 80 | page.getByRole('button', { name: 'OK', exact: true }).click(), 81 | ]); 82 | }); 83 | 84 | test('Playwright: deletes a pokemon', async ({ page }) => { 85 | await page.locator('[data-cy="pokemon-list"]'); 86 | 87 | await page.locator('[data-cy="pokemon-card"]').first().click(); 88 | await page.locator('[data-cy="pokemon-card"] [data-cy="delete-pokemon-button"]').first().click(); 89 | }); 90 | -------------------------------------------------------------------------------- /serverless/.env.template: -------------------------------------------------------------------------------- 1 | TRACETEST_AGENT_ENDPOINT= 2 | TRACETEST_API_TOKEN= -------------------------------------------------------------------------------- /serverless/.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless 7 | .env 8 | -------------------------------------------------------------------------------- /serverless/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true -------------------------------------------------------------------------------- /serverless/.nvmrc: -------------------------------------------------------------------------------- 1 | v20.11.1 2 | -------------------------------------------------------------------------------- /serverless/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kubeshop/pokeshop/3be5812abce5efaedf426c29b11da5b0b5b3ea64/serverless/README.md -------------------------------------------------------------------------------- /serverless/config.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "DATABASE_URL": "postgresql://admin:admin@localhost:5432/pokeshop?schema=public" 3 | } 4 | -------------------------------------------------------------------------------- /serverless/infra/elasticache.yml: -------------------------------------------------------------------------------- 1 | Resources: 2 | AppSecurityGroupEgressCache: 3 | Type: "AWS::EC2::SecurityGroupEgress" 4 | Properties: 5 | Description: "permit Redis (5432) to CacheSecurityGroup" 6 | DestinationSecurityGroupId: !Ref CacheSecurityGroup 7 | FromPort: 6379 8 | GroupId: !GetAtt AppSecurityGroup.GroupId 9 | IpProtocol: tcp 10 | ToPort: 6379 11 | CacheSecurityGroup: 12 | Type: "AWS::EC2::SecurityGroup" 13 | Properties: 14 | GroupDescription: "Elasticache Security Group" 15 | SecurityGroupEgress: 16 | - Description: "deny all outbound" 17 | IpProtocol: "-1" 18 | CidrIp: "127.0.0.1/32" 19 | SecurityGroupIngress: 20 | - Description: "permit Redis (6379) from AppSecurityGroup" 21 | FromPort: 6379 22 | IpProtocol: tcp 23 | SourceSecurityGroupId: !GetAtt AppSecurityGroup.GroupId 24 | ToPort: 6379 25 | Tags: 26 | - Key: Name 27 | Value: !Join ["-", [!Ref "AWS::StackName", elasticache]] 28 | VpcId: !Ref VPC 29 | PokeCache: 30 | Type: "AWS::ElastiCache::CacheCluster" 31 | DependsOn: VPC 32 | Properties: 33 | ClusterName: ${self:custom.databaseName} 34 | Engine: redis 35 | CacheNodeType: cache.t2.micro 36 | NumCacheNodes: "1" 37 | CacheSubnetGroupName: !Ref ElastiCacheSubnetGroup 38 | VpcSecurityGroupIds: 39 | - !Ref CacheSecurityGroup 40 | -------------------------------------------------------------------------------- /serverless/infra/queue.yml: -------------------------------------------------------------------------------- 1 | Resources: 2 | AssetsQueue: 3 | Type: "AWS::SQS::Queue" 4 | Properties: 5 | QueueName: !Join 6 | - "-" 7 | - - !Ref "AWS::StackName" 8 | - queue.fifo 9 | FifoQueue: true 10 | ContentBasedDeduplication: true 11 | -------------------------------------------------------------------------------- /serverless/infra/rds.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Resources: 3 | AppSecurityGroupEgressRDS: 4 | Type: "AWS::EC2::SecurityGroupEgress" 5 | Properties: 6 | Description: "permit PostgreSQL (5432) to DBSecurityGroup" 7 | DestinationSecurityGroupId: !Ref DBSecurityGroup 8 | FromPort: 5432 9 | GroupId: !GetAtt AppSecurityGroup.GroupId 10 | IpProtocol: tcp 11 | ToPort: 5432 12 | DBSecurityGroup: 13 | Type: "AWS::EC2::SecurityGroup" 14 | Properties: 15 | GroupDescription: "RDS Security Group" 16 | SecurityGroupEgress: 17 | - Description: "deny all outbound" 18 | IpProtocol: "-1" 19 | CidrIp: "127.0.0.1/32" 20 | SecurityGroupIngress: 21 | - Description: "permit PostgreSQL (5432) from AppSecurityGroup" 22 | FromPort: 5432 23 | IpProtocol: tcp 24 | SourceSecurityGroupId: !GetAtt AppSecurityGroup.GroupId 25 | ToPort: 5432 26 | Tags: 27 | - Key: Name 28 | Value: !Join ["-", [!Ref "AWS::StackName", rds]] 29 | VpcId: !Ref VPC 30 | PokeDatabase: 31 | Type: "AWS::RDS::DBInstance" 32 | DependsOn: VPC 33 | Properties: 34 | DBName: ${self:custom.databaseName} 35 | AllocatedStorage: "5" 36 | DBInstanceClass: "db.t3.micro" 37 | Engine: "Postgres" 38 | MasterUsername: ${self:custom.databaseUsername} 39 | MasterUserPassword: ${self:custom.databasePassword} 40 | DBSubnetGroupName: !Ref RDSSubnetGroup 41 | VPCSecurityGroups: 42 | - !Ref DBSecurityGroup 43 | 44 | Outputs: 45 | PokeDatabaseEndpoint: 46 | Description: The Poke Database Endpoint 47 | Value: !GetAtt "PokeDatabase.Endpoint.Address" 48 | -------------------------------------------------------------------------------- /serverless/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "handler.js", 6 | "scripts": { 7 | "deploy": "sls deploy", 8 | "remove": "sls remove", 9 | "test": "ts-node tracetest.ts" 10 | }, 11 | "engines": { 12 | "node": ">=20.0.0" 13 | }, 14 | "author": "", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "@types/aws-lambda": "^8.10.93", 18 | "@types/debug": "^4.1.7", 19 | "@types/node": "^20.11.19", 20 | "@types/node-fetch": "^2.6.1", 21 | "@types/validator": "^13.7.2", 22 | "serverless-offline": "^13.3.3", 23 | "serverless-plugin-typescript": "^2.1.1", 24 | "serverless-vpc-plugin": "^1.0.4", 25 | "typescript": "^5.3.3" 26 | }, 27 | "dependencies": { 28 | "@aws-sdk/client-sqs": "^3.515.0", 29 | "@lambda-middleware/class-validator": "^2.0.1", 30 | "@lambda-middleware/compose": "^1.2.0", 31 | "@lambda-middleware/cors": "^2.0.0", 32 | "@lambda-middleware/json-serializer": "^2.1.1", 33 | "@lambda-middleware/utils": "^1.0.4", 34 | "@opentelemetry/api": "^1.9.0", 35 | "@opentelemetry/auto-instrumentations-node": "^0.50.0", 36 | "@opentelemetry/exporter-trace-otlp-grpc": "^0.53.0", 37 | "@opentelemetry/exporter-trace-otlp-http": "^0.53.0", 38 | "@opentelemetry/instrumentation": "^0.53.0", 39 | "@opentelemetry/instrumentation-fetch": "^0.53.0", 40 | "@opentelemetry/sdk-trace-base": "^1.26.0", 41 | "@opentelemetry/sdk-trace-node": "^1.26.0", 42 | "@tracetest/client": "^0.2.0", 43 | "class-validator": "^0.13.2", 44 | "dotenv": "^16.4.5", 45 | "ioredis": "^5.3.2", 46 | "lodash": "^4.17.21", 47 | "node-fetch": "^2.6.7", 48 | "pg": "^8.11.3", 49 | "reflect-metadata": "^0.2.1", 50 | "sequelize": "^6.37.0", 51 | "sequelize-typescript": "^2.1.6", 52 | "ts-node": "^10.9.2", 53 | "umzug": "^3.7.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /serverless/serverless.yml: -------------------------------------------------------------------------------- 1 | --- 2 | service: pokeshop-api-demo 3 | frameworkVersion: "3" 4 | 5 | plugins: 6 | - serverless-offline 7 | - serverless-plugin-typescript 8 | - serverless-vpc-plugin 9 | 10 | useDotenv: true 11 | 12 | custom: 13 | databaseName: "pokeshop" 14 | databaseUsername: "ashketchum" 15 | databasePassword: "squirtle123" 16 | databaseEndpoint: 17 | Fn::GetAtt: [PokeDatabase, "Endpoint.Address"] 18 | vpcConfig: 19 | cidrBlock: "10.0.0.0/16" 20 | createNetworkAcl: false 21 | createDbSubnet: true 22 | createNatInstance: true 23 | zones: 24 | - us-east-1a 25 | - us-east-1b 26 | subnetGroups: 27 | - rds 28 | - elasticache 29 | 30 | provider: 31 | name: aws 32 | runtime: nodejs20.x 33 | environment: 34 | NODE_OPTIONS: --require ./src/setup 35 | COLLECTOR_ENDPOINT: ${env:TRACETEST_AGENT_ENDPOINT} 36 | DATABASE_URL: 37 | Fn::Join: 38 | - "" 39 | - - "postgres://" 40 | - ${self:custom.databaseUsername} 41 | - ":" 42 | - ${self:custom.databasePassword} 43 | - "@" 44 | - ${self:custom.databaseEndpoint} 45 | - "/" 46 | - ${self:custom.databaseName} 47 | - "?schema=public" 48 | SQS_QUEUE_URL: { Ref: AssetsQueue } 49 | POKE_API_BASE_URL: "https://pokeapi.co/api/v2" 50 | REDIS_URL: 51 | Fn::GetAtt: [PokeCache, "RedisEndpoint.Address"] 52 | iam: 53 | role: 54 | statements: 55 | - Effect: Allow 56 | Action: 57 | - sqs:* 58 | Resource: 59 | Fn::GetAtt: [AssetsQueue, Arn] 60 | 61 | functions: 62 | get: 63 | handler: ./src/handler.get 64 | events: 65 | - httpApi: 66 | path: /pokemon 67 | method: get 68 | create: 69 | handler: ./src/handler.create 70 | events: 71 | - httpApi: 72 | path: /pokemon 73 | method: post 74 | import: 75 | handler: ./src/handler.importPokemon 76 | events: 77 | - httpApi: 78 | path: /pokemon/import 79 | method: post 80 | worker: 81 | handler: ./src/handler.worker 82 | events: 83 | - sqs: 84 | arn: 85 | Fn::GetAtt: 86 | - AssetsQueue 87 | - Arn 88 | 89 | # update: 90 | # handler: ./src/handler.update 91 | # events: 92 | # - httpApi: 93 | # path: /pokemon/{id} 94 | # method: patch 95 | # remove: 96 | # handler: ./src/handler.remove 97 | # events: 98 | # - httpApi: 99 | # path: /pokemon/{id} 100 | # method: delete 101 | # featured: 102 | # handler: ./src/handler.featured 103 | # events: 104 | # - httpApi: 105 | # path: /pokemon/featured 106 | # method: get 107 | 108 | resources: 109 | - ${file(./infra/rds.yml)} 110 | - ${file(./infra/queue.yml)} 111 | - ${file(./infra/elasticache.yml)} 112 | -------------------------------------------------------------------------------- /serverless/src/constants/tags.ts: -------------------------------------------------------------------------------- 1 | export enum CustomTags { 2 | HTTP_RESPONSE_BODY = 'http.response.body', 3 | RPC_RESPONSE_BODY = 'rpc.response.body', 4 | RPC_REQUEST_BODY = 'rpc.request.body', 5 | HTTP_REQUEST_BODY = 'http.request.body', 6 | VALIDATION_IS_VALID = 'validation.is_valid', 7 | VALIDATION_ERRORS = 'validation.errors', 8 | HTTP_REQUEST_HEADER = 'http.request.header', 9 | HTTP_RESPONSE_HEADER = 'http.response.header', 10 | DB_PAYLOAD = 'db.payload', 11 | DB_RESULT = 'db.result', 12 | MESSAGING_PAYLOAD = 'messaging.payload', 13 | MESSAGING_HEADER = 'messaging.header', 14 | CACHE_HIT = 'cache.hit', 15 | } 16 | -------------------------------------------------------------------------------- /serverless/src/handler.ts: -------------------------------------------------------------------------------- 1 | import createHandler from './handlers/create.handler'; 2 | import getHandler from './handlers/get.handler'; 3 | import featuredHandler from './handlers/featured.handler'; 4 | import removeHandler from './handlers/remove.handler'; 5 | import importPokemonHandler from './handlers/import.handler'; 6 | import searchHandler from './handlers/search.handler'; 7 | import composeHandlers from './middlewares/middleware'; 8 | import worker from './handlers/worker.handler'; 9 | 10 | const { create, get, update, remove, importPokemon, search, featured } = composeHandlers({ 11 | create: createHandler, 12 | get: getHandler, 13 | remove: removeHandler, 14 | importPokemon: importPokemonHandler, 15 | search: searchHandler, 16 | featured: featuredHandler, 17 | }); 18 | 19 | export { create, get, update, remove, importPokemon, featured, search, worker }; 20 | -------------------------------------------------------------------------------- /serverless/src/handlers/create.handler.ts: -------------------------------------------------------------------------------- 1 | import { PromiseHandler } from '@lambda-middleware/utils'; 2 | import { composeHandler } from '@lambda-middleware/compose'; 3 | import { classValidator } from '@lambda-middleware/class-validator'; 4 | import CreatePokemon from '../validators/createPokemon'; 5 | import { getPokemonRepository, Pokemon } from '../repositories'; 6 | 7 | const create: PromiseHandler<{ body: CreatePokemon }> = async ({body}) => { 8 | const { name = '', type = '', isFeatured = false, imageUrl = '' } = body; 9 | 10 | const repository = getPokemonRepository(); 11 | 12 | const pokemon = await repository.create( 13 | new Pokemon({ 14 | name, 15 | type, 16 | isFeatured, 17 | imageUrl, 18 | }) 19 | ); 20 | 21 | return pokemon; 22 | }; 23 | 24 | export default composeHandler( 25 | classValidator({ 26 | bodyType: CreatePokemon, 27 | }), 28 | create 29 | ); 30 | -------------------------------------------------------------------------------- /serverless/src/handlers/featured.handler.ts: -------------------------------------------------------------------------------- 1 | import { PromiseHandler } from '@lambda-middleware/utils'; 2 | 3 | const featured: PromiseHandler = async () => { 4 | return {}; 5 | }; 6 | 7 | export default featured; 8 | -------------------------------------------------------------------------------- /serverless/src/handlers/get.handler.ts: -------------------------------------------------------------------------------- 1 | import { PromiseHandler } from '@lambda-middleware/utils'; 2 | import { getPokemonRepository } from '../repositories'; 3 | 4 | const get: PromiseHandler = async ({ queryStringParameters }) => { 5 | const { skip = '0', take = '100' } = queryStringParameters || {}; 6 | const query = { skip: +skip, take: +take }; 7 | const repository = getPokemonRepository(); 8 | 9 | const [items, totalCount] = await Promise.all([repository.findMany(query), repository.count()]); 10 | 11 | return { 12 | totalCount, 13 | items, 14 | }; 15 | }; 16 | 17 | export default get; 18 | -------------------------------------------------------------------------------- /serverless/src/handlers/import.handler.ts: -------------------------------------------------------------------------------- 1 | import { PromiseHandler } from '@lambda-middleware/utils'; 2 | import { composeHandler } from '@lambda-middleware/compose'; 3 | import { classValidator } from '@lambda-middleware/class-validator'; 4 | import ImportPokemon from '../validators/importPokemon'; 5 | import PokemonSynchronizer from '../services/pokemonSynchronizer.service'; 6 | import PokeAPIService from '../services/pokeApi.service'; 7 | 8 | const pokeApiService = new PokeAPIService(); 9 | const syncronizer = PokemonSynchronizer(pokeApiService); 10 | 11 | const importPokemon: PromiseHandler<{ body: { id } }> = async ({ body: { id } }) => { 12 | await syncronizer.queue({ id }); 13 | 14 | return { 15 | id, 16 | }; 17 | }; 18 | 19 | export default composeHandler( 20 | classValidator({ 21 | bodyType: ImportPokemon, 22 | }), 23 | importPokemon 24 | ); 25 | -------------------------------------------------------------------------------- /serverless/src/handlers/remove.handler.ts: -------------------------------------------------------------------------------- 1 | import { PromiseHandler } from '@lambda-middleware/utils'; 2 | 3 | const remove: PromiseHandler = async () => { 4 | return {}; 5 | }; 6 | 7 | export default remove; 8 | -------------------------------------------------------------------------------- /serverless/src/handlers/search.handler.ts: -------------------------------------------------------------------------------- 1 | import { PromiseHandler } from '@lambda-middleware/utils'; 2 | 3 | const search: PromiseHandler = async () => { 4 | return {}; 5 | }; 6 | 7 | export default search; 8 | -------------------------------------------------------------------------------- /serverless/src/handlers/worker.handler.ts: -------------------------------------------------------------------------------- 1 | import { SQSHandler } from 'aws-lambda'; 2 | import PokeAPIService from '../services/pokeApi.service'; 3 | import PokemonSynchronizer from '../services/pokemonSynchronizer.service'; 4 | import db from '../middlewares/db'; 5 | 6 | const pokeApiService = new PokeAPIService(); 7 | const syncronizer = PokemonSynchronizer(pokeApiService); 8 | 9 | const isDbReady = db(); 10 | 11 | const worker: SQSHandler = async ({ Records }) => { 12 | await isDbReady; 13 | 14 | await Promise.all( 15 | Records.map(async message => { 16 | await syncronizer.sync(message); 17 | }) 18 | ); 19 | }; 20 | 21 | export default worker; 22 | -------------------------------------------------------------------------------- /serverless/src/middlewares/db.ts: -------------------------------------------------------------------------------- 1 | import { PromiseHandler } from '@lambda-middleware/utils'; 2 | import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; 3 | import { setupSequelize } from '../utils/db'; 4 | 5 | const isDBReady = setupSequelize(); 6 | 7 | const db = 8 | () => 9 | ( 10 | handler: PromiseHandler 11 | ): PromiseHandler => 12 | async (event: APIGatewayProxyEvent, context: Context): Promise => { 13 | await isDBReady; 14 | return handler(event, context); 15 | }; 16 | 17 | export default db; 18 | -------------------------------------------------------------------------------- /serverless/src/middlewares/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import { PromiseHandler } from '@lambda-middleware/utils'; 2 | import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; 3 | import debugFactory, { IDebugger } from 'debug'; 4 | 5 | const logger: IDebugger = debugFactory('@lambda-middleware/error-handler'); 6 | 7 | const errorHandler = 8 | () => 9 | ( 10 | handler: PromiseHandler 11 | ): PromiseHandler => 12 | async (event: APIGatewayProxyEvent, context: Context): Promise => { 13 | try { 14 | return await handler(event, context); 15 | } catch (error) { 16 | console.log('[❌ - ERROR]', error); 17 | const { statusCode } = error as { statusCode?: number }; 18 | if (typeof statusCode === 'number' && statusCode < 500) { 19 | logger(`Responding with full error as statusCode is ${statusCode}`); 20 | return { 21 | body: JSON.stringify(error), 22 | headers: { 23 | 'Content-Type': 'application/json', 24 | }, 25 | statusCode: statusCode, 26 | }; 27 | } 28 | logger('Responding with internal server error'); 29 | return { 30 | body: JSON.stringify({ 31 | message: 'Internal server error', 32 | statusCode: 500, 33 | }), 34 | headers: { 35 | 'Content-Type': 'application/json', 36 | }, 37 | statusCode: 500, 38 | }; 39 | } 40 | }; 41 | 42 | export default errorHandler; 43 | -------------------------------------------------------------------------------- /serverless/src/middlewares/instrumentation.ts: -------------------------------------------------------------------------------- 1 | import { PromiseHandler } from '@lambda-middleware/utils'; 2 | import { snakeCase } from 'lodash'; 3 | import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; 4 | import { SpanKind, SpanStatusCode } from '@opentelemetry/api'; 5 | import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; 6 | import { CustomTags } from '../constants/tags'; 7 | import { createSpan, getParentSpan, runWithSpan } from '../telemetry/tracing'; 8 | 9 | const instrumentation = 10 | () => 11 | ( 12 | handler: PromiseHandler 13 | ): PromiseHandler => 14 | async (event: APIGatewayProxyEvent, context: Context): Promise => { 15 | const { 16 | headers, 17 | body, 18 | requestContext: { routeKey }, 19 | } = event; 20 | 21 | const parentSpan = await getParentSpan(); 22 | const span = await createSpan(`${routeKey}`, parentSpan, { kind: SpanKind.SERVER }); 23 | 24 | return runWithSpan(span, async () => { 25 | try { 26 | return handler(event, context); 27 | } catch (error) { 28 | span.recordException(error); 29 | span.setStatus({ code: SpanStatusCode.ERROR }); 30 | 31 | return { 32 | body: JSON.stringify({ 33 | message: 'Internal server error', 34 | statusCode: 500, 35 | }), 36 | headers: { 37 | 'Content-Type': 'application/json', 38 | }, 39 | statusCode: 500, 40 | }; 41 | } finally { 42 | Object.entries(headers).forEach(([key, value]) => { 43 | span.setAttribute(`${CustomTags.HTTP_REQUEST_HEADER}.${snakeCase(key)}`, JSON.stringify([value])); 44 | }); 45 | 46 | span.setAttributes({ 47 | [CustomTags.HTTP_REQUEST_BODY]: JSON.stringify(body), 48 | [SemanticAttributes.HTTP_USER_AGENT]: headers['user-agent'] || '', 49 | }); 50 | 51 | span.end(); 52 | } 53 | }); 54 | }; 55 | 56 | export default instrumentation; 57 | -------------------------------------------------------------------------------- /serverless/src/middlewares/middleware.ts: -------------------------------------------------------------------------------- 1 | import { composeHandler } from '@lambda-middleware/compose'; 2 | import { PromiseHandler } from '@lambda-middleware/utils'; 3 | import { jsonSerializer } from '@lambda-middleware/json-serializer'; 4 | import errorHandler from './errorHandler'; 5 | import instrumentation from './instrumentation'; 6 | import db from './db'; 7 | 8 | export const composeMiddleware = (handler: PromiseHandler) => 9 | composeHandler( 10 | errorHandler(), 11 | db(), 12 | instrumentation(), 13 | jsonSerializer(), 14 | handler 15 | ); 16 | 17 | type THandlerMap = Record; 18 | 19 | export const composeHandlers = (handlerMap: THandlerMap) => 20 | Object.entries(handlerMap).reduce( 21 | (acc, [handlerName, handler]) => ({ 22 | ...acc, 23 | [handlerName]: composeMiddleware(handler), 24 | }), 25 | {} 26 | ); 27 | 28 | export default composeHandlers; 29 | -------------------------------------------------------------------------------- /serverless/src/migrations/01-pokemon-table.js: -------------------------------------------------------------------------------- 1 | const { Sequelize } = require('sequelize'); 2 | 3 | module.exports = { 4 | up: async ({ context: queryInterface }) => { 5 | return await queryInterface.createTable('pokemon', { 6 | id: { 7 | type: Sequelize.DataTypes.INTEGER, 8 | autoIncrement: true, 9 | primaryKey: true 10 | }, 11 | name: Sequelize.DataTypes.STRING, 12 | type: Sequelize.DataTypes.STRING, 13 | isFeatured: { 14 | type: Sequelize.DataTypes.BOOLEAN, 15 | defaultValue: false, 16 | allowNull: false 17 | }, 18 | imageUrl: { 19 | type: Sequelize.DataTypes.STRING, 20 | allowNull: true 21 | }, 22 | createdAt: { 23 | type: Sequelize.DataTypes.DATE, 24 | allowNull: false, 25 | defaultValue: Sequelize.DataTypes.NOW 26 | }, 27 | updatedAt: { 28 | type: Sequelize.DataTypes.DATE, 29 | allowNull: true 30 | }, 31 | }); 32 | }, 33 | down: async ({ context: queryInterface }) => { 34 | return await queryInterface.dropTable('pokemon'); 35 | } 36 | }; -------------------------------------------------------------------------------- /serverless/src/repositories/index.ts: -------------------------------------------------------------------------------- 1 | import { PokemonRepository, Pokemon } from './pokemon.repository'; 2 | import { InstrumentedPokemonRepository } from './instrumented.repository'; 3 | import { PokemonModel, SequelizePokemonRepository } from './pokemon.sequelize.repository'; 4 | 5 | function getPokemonRepository(): PokemonRepository { 6 | const realPokemonRepository = new SequelizePokemonRepository(); 7 | return new InstrumentedPokemonRepository(realPokemonRepository, PokemonModel); 8 | } 9 | 10 | export { Pokemon, getPokemonRepository, PokemonRepository }; 11 | -------------------------------------------------------------------------------- /serverless/src/repositories/pokemon.repository.ts: -------------------------------------------------------------------------------- 1 | export class Pokemon { 2 | public id?: number; 3 | public name: string; 4 | public type: string; 5 | public isFeatured: boolean; 6 | public imageUrl?: string; 7 | 8 | public constructor(data?: object | undefined) { 9 | if (data) { 10 | this.id = data['id']; 11 | this.imageUrl = data['imageUrl']; 12 | this.isFeatured = data['isFeatured']; 13 | this.type = data['type']; 14 | this.name = data['name']; 15 | } 16 | } 17 | } 18 | 19 | export type SearchOptions = { 20 | where?: object | undefined; 21 | skip?: number | undefined; 22 | take?: number | undefined; 23 | }; 24 | 25 | export interface PokemonRepository { 26 | create(pokemon: Pokemon): Promise; 27 | update(id: number, pokemon: Pokemon): Promise; 28 | delete(pokemonId: number): Promise; 29 | findOne(id: number): Promise; 30 | findMany(options?: SearchOptions | undefined): Promise; 31 | count(options?: SearchOptions | undefined): Promise; 32 | } 33 | -------------------------------------------------------------------------------- /serverless/src/repositories/pokemon.sequelize.repository.ts: -------------------------------------------------------------------------------- 1 | import { Column, PrimaryKey, Model, Table, AutoIncrement, AllowNull, Default } from 'sequelize-typescript'; 2 | import { Pokemon, PokemonRepository, SearchOptions } from './pokemon.repository'; 3 | 4 | @Table({ 5 | schema: 'public', 6 | tableName: 'pokemon', 7 | }) 8 | export class PokemonModel extends Model { 9 | @PrimaryKey 10 | @AutoIncrement 11 | @Column 12 | public id?: number; 13 | 14 | @Column 15 | public name: string; 16 | 17 | @Column 18 | public type: string; 19 | 20 | @AllowNull 21 | @Column 22 | public imageUrl?: string; 23 | 24 | @Default(false) 25 | @Column 26 | public isFeatured: boolean; 27 | } 28 | 29 | export class SequelizePokemonRepository implements PokemonRepository { 30 | async create(pokemon: Pokemon): Promise { 31 | const model = this.createModelFromPokemon(pokemon); 32 | await model.save(); 33 | 34 | return this.createPokemonFromModel(model); 35 | } 36 | 37 | async update(id: number, pokemon: Pokemon): Promise { 38 | const newData = { ...pokemon }; 39 | delete newData.id; 40 | await PokemonModel.update({ ...newData }, { where: { id: id } }); 41 | 42 | const model = await PokemonModel.findOne({ 43 | where: { id: id }, 44 | }); 45 | return this.createPokemonFromModel(model!); 46 | } 47 | 48 | async delete(id: number): Promise { 49 | return await PokemonModel.destroy({ where: { id: id } }); 50 | } 51 | 52 | async findOne(id: number): Promise { 53 | const model = await PokemonModel.findOne({ where: { id: id } }); 54 | if (!model) { 55 | return null; 56 | } 57 | 58 | return this.createPokemonFromModel(model); 59 | } 60 | 61 | async findMany(options?: SearchOptions): Promise { 62 | const models = await PokemonModel.findAll({ 63 | where: { ...options?.where }, 64 | offset: options?.skip, 65 | limit: options?.take, 66 | }); 67 | 68 | return models.map(model => this.createPokemonFromModel(model)); 69 | } 70 | 71 | async count(options?: SearchOptions): Promise { 72 | return await PokemonModel.count({ 73 | where: { ...options?.where }, 74 | }); 75 | } 76 | 77 | private createModelFromPokemon(pokemon: Pokemon): PokemonModel { 78 | const model = new PokemonModel(); 79 | model.id = pokemon.id; 80 | model.name = pokemon.name; 81 | model.type = pokemon.type; 82 | model.isFeatured = pokemon.isFeatured; 83 | model.imageUrl = pokemon.imageUrl; 84 | 85 | return model; 86 | } 87 | 88 | private createPokemonFromModel(model: PokemonModel): Pokemon { 89 | return new Pokemon({ 90 | id: model.id, 91 | name: model.name, 92 | type: model.type, 93 | isFeatured: model.isFeatured, 94 | imageUrl: model.imageUrl, 95 | }); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /serverless/src/services/fetured.service.ts: -------------------------------------------------------------------------------- 1 | import { getCacheService } from '../services/cache.service'; 2 | import { getPokemonRepository, Pokemon } from '../repositories'; 3 | 4 | const FeaturedService = () => { 5 | const cacheService = getCacheService(); 6 | const repository = getPokemonRepository(); 7 | const key = 'featured-list'; 8 | 9 | return { 10 | async get() { 11 | const fromCache = await cacheService.get(key); 12 | 13 | if (!!fromCache) return fromCache; 14 | 15 | const pokemons = await repository.findMany({ where: { isFeatured: true } }); 16 | await cacheService.set(key, pokemons); 17 | 18 | return pokemons; 19 | }, 20 | }; 21 | }; 22 | 23 | export default FeaturedService; 24 | -------------------------------------------------------------------------------- /serverless/src/services/pokeApi.service.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { snakeCase } from 'lodash'; 3 | import { getParentSpan, createSpan, runWithSpan } from '../telemetry/tracing'; 4 | import { Span, SpanKind } from '@opentelemetry/api'; 5 | import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; 6 | import { CustomTags } from '../constants/tags'; 7 | 8 | const { POKE_API_BASE_URL = '' } = process.env; 9 | 10 | type TRawPokemon = { 11 | name: string; 12 | types: Array<{ 13 | type: { 14 | name: string; 15 | }; 16 | }>; 17 | sprites: { 18 | front_default: string; 19 | }; 20 | }; 21 | 22 | export type TPokemon = { 23 | name: string; 24 | type: string; 25 | imageUrl: string; 26 | }; 27 | 28 | class PokeAPIService { 29 | private readonly baseRoute: string = '/pokemon'; 30 | private readonly baseUrl: string = `${POKE_API_BASE_URL}${this.baseRoute}`; 31 | 32 | async getPokemon(id: string): Promise { 33 | const parentSpan = await getParentSpan(); 34 | const span = await createSpan('GET', parentSpan, { kind: SpanKind.CLIENT }); 35 | 36 | try { 37 | return await this.getPokemonFromAPi(id, span); 38 | } finally { 39 | span.end(); 40 | } 41 | } 42 | 43 | private async getPokemonFromAPi(id: string, span: Span): Promise { 44 | return await runWithSpan(span, async () => { 45 | const {hostname, protocol, pathname} = new URL(`${this.baseUrl}/${id}`); 46 | 47 | span.setAttributes({ 48 | [SemanticAttributes.HTTP_URL]: `${this.baseUrl}/${id}`, 49 | [SemanticAttributes.HTTP_METHOD]: 'GET', 50 | [SemanticAttributes.HTTP_ROUTE]: pathname, 51 | [SemanticAttributes.HTTP_SCHEME]: protocol, 52 | [SemanticAttributes.NET_PEER_NAME]: hostname, 53 | }); 54 | 55 | const response = await fetch(`${this.baseUrl}/${id}`, { 56 | method: 'GET', 57 | }); 58 | 59 | const pokemon = (await response.json()) as TRawPokemon; 60 | 61 | span.setAttributes({ 62 | [SemanticAttributes.HTTP_STATUS_CODE]: response.status, 63 | [SemanticAttributes.HTTP_RESPONSE_CONTENT_LENGTH]: JSON.stringify(pokemon).length, 64 | [CustomTags.HTTP_RESPONSE_BODY]: JSON.stringify({ name: pokemon.name }), 65 | }); 66 | 67 | Object.entries(response.headers).forEach(([key, value]) => { 68 | span.setAttribute(`${CustomTags.HTTP_RESPONSE_HEADER}.${snakeCase(key)}`, JSON.stringify([value])); 69 | }); 70 | 71 | const { name, types, sprites } = pokemon; 72 | 73 | return { 74 | name, 75 | type: types.map(({ type }) => type.name).join(','), 76 | imageUrl: sprites.front_default, 77 | }; 78 | }); 79 | } 80 | } 81 | 82 | export default PokeAPIService; 83 | -------------------------------------------------------------------------------- /serverless/src/services/pokemonSynchronizer.service.ts: -------------------------------------------------------------------------------- 1 | import { SpanKind } from '@opentelemetry/api'; 2 | import { SQSRecord } from 'aws-lambda'; 3 | import { getPokemonRepository, Pokemon } from '../repositories'; 4 | import { createQueueService } from './queue.service'; 5 | import { createSpan, getParentSpan, runWithSpan } from '../telemetry/tracing'; 6 | import { getCacheService } from './cache.service'; 7 | import { TPokemon } from './pokeApi.service'; 8 | 9 | export const MESSAGE_GROUP = 'queue.synchronizePokemon'; 10 | 11 | export type TPokemonSyncMessage = { 12 | id: number; 13 | }; 14 | 15 | const PokemonSynchronizer = pokeApiService => { 16 | const queue = createQueueService(MESSAGE_GROUP); 17 | const repository = getPokemonRepository(); 18 | const cache = getCacheService(); 19 | 20 | return { 21 | async queue(message: TPokemonSyncMessage) { 22 | return queue.send(message); 23 | }, 24 | async sync(message: SQSRecord) { 25 | return queue.withWorker(message, async ({ body }) => { 26 | const { id }: TPokemonSyncMessage = JSON.parse(body); 27 | const parentSpan = await getParentSpan(); 28 | const span = await createSpan('import pokemon', parentSpan, { kind: SpanKind.INTERNAL }); 29 | 30 | try { 31 | return await runWithSpan(span, async () => { 32 | let pokemon = await cache.get(`pokemon_${id}`); 33 | if (!pokemon) { 34 | pokemon = await pokeApiService.getPokemon(id); 35 | await cache.set(`pokemon_${id}`, pokemon!); 36 | } 37 | 38 | await repository.create(new Pokemon({ ...pokemon })); 39 | }); 40 | } finally { 41 | span.end(); 42 | } 43 | }); 44 | }, 45 | }; 46 | }; 47 | 48 | export default PokemonSynchronizer; 49 | -------------------------------------------------------------------------------- /serverless/src/setup.ts: -------------------------------------------------------------------------------- 1 | import { getTracer } from './telemetry/tracing'; 2 | 3 | getTracer(); 4 | -------------------------------------------------------------------------------- /serverless/src/telemetry/instrumented.component.ts: -------------------------------------------------------------------------------- 1 | import { Span, SpanKind, SpanStatusCode } from '@opentelemetry/api'; 2 | import { createSpan, getParentSpan, runWithSpan } from '../telemetry/tracing'; 3 | 4 | export abstract class InstrumentedComponent { 5 | protected async instrumentMethod( 6 | spanName: string, 7 | spanKind: SpanKind | undefined, 8 | innerMethod: (span?: Span) => Promise 9 | ): Promise { 10 | const parentSpan = await getParentSpan(); 11 | const span = await createSpan(spanName, parentSpan, { kind: spanKind || SpanKind.INTERNAL }); 12 | try { 13 | return await runWithSpan(span, () => innerMethod(span)); 14 | } catch (ex) { 15 | span.recordException(ex); 16 | span.setStatus({ code: SpanStatusCode.ERROR }); 17 | throw ex; 18 | } finally { 19 | span.end(); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /serverless/src/telemetry/tracing.ts: -------------------------------------------------------------------------------- 1 | import * as opentelemetry from '@opentelemetry/api'; 2 | import * as dotenv from 'dotenv'; 3 | import { SpanStatusCode } from '@opentelemetry/api'; 4 | import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api'; 5 | 6 | import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; 7 | import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc'; 8 | import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; 9 | import { registerInstrumentations } from '@opentelemetry/instrumentation'; 10 | import { AwsLambdaInstrumentation } from '@opentelemetry/instrumentation-aws-lambda'; 11 | 12 | diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG); 13 | 14 | // Make sure all env variables are available in process.env 15 | dotenv.config(); 16 | 17 | const { COLLECTOR_ENDPOINT = '', SERVICE_NAME = 'pokeshop' } = process.env; 18 | 19 | let globalTracer: opentelemetry.Tracer | null = null; 20 | 21 | async function createTracer(): Promise { 22 | const provider = new NodeTracerProvider(); 23 | 24 | const spanProcessor = new BatchSpanProcessor( 25 | new OTLPTraceExporter({ 26 | url: COLLECTOR_ENDPOINT, 27 | }) 28 | ); 29 | 30 | provider.addSpanProcessor(spanProcessor); 31 | provider.register(); 32 | 33 | registerInstrumentations({ 34 | instrumentations: [ 35 | new AwsLambdaInstrumentation({ 36 | disableAwsContextPropagation: true, 37 | }), 38 | ], 39 | }); 40 | 41 | const tracer = provider.getTracer(SERVICE_NAME); 42 | 43 | globalTracer = tracer; 44 | 45 | return globalTracer; 46 | } 47 | 48 | async function getTracer(): Promise { 49 | if (globalTracer) { 50 | return globalTracer; 51 | } 52 | 53 | return createTracer(); 54 | } 55 | 56 | async function getParentSpan(): Promise { 57 | const parentSpan = opentelemetry.trace.getSpan(opentelemetry.context.active()); 58 | if (!parentSpan) { 59 | return undefined; 60 | } 61 | 62 | return parentSpan; 63 | } 64 | 65 | async function createSpan( 66 | name: string, 67 | parentSpan?: opentelemetry.Span | undefined, 68 | options?: opentelemetry.SpanOptions | undefined 69 | ): Promise { 70 | const tracer = await getTracer(); 71 | if (parentSpan) { 72 | const context = opentelemetry.trace.setSpan(opentelemetry.context.active(), parentSpan); 73 | 74 | return createSpanFromContext(name, context, options); 75 | } 76 | 77 | return tracer.startSpan(name); 78 | } 79 | 80 | async function createSpanFromContext( 81 | name: string, 82 | ctx: opentelemetry.Context, 83 | options?: opentelemetry.SpanOptions | undefined 84 | ): Promise { 85 | const tracer = await getTracer(); 86 | if (!ctx) { 87 | return tracer.startSpan(name, options, opentelemetry.context.active()); 88 | } 89 | 90 | return tracer.startSpan(name, options, ctx); 91 | } 92 | 93 | async function runWithSpan(parentSpan: opentelemetry.Span, fn: () => Promise): Promise { 94 | const ctx = opentelemetry.trace.setSpan(opentelemetry.context.active(), parentSpan); 95 | 96 | try { 97 | return await opentelemetry.context.with(ctx, fn); 98 | } catch (ex) { 99 | parentSpan.recordException(ex); 100 | parentSpan.setStatus({ code: SpanStatusCode.ERROR }); 101 | throw ex; 102 | } 103 | } 104 | 105 | export { getTracer, getParentSpan, createSpan, createSpanFromContext, runWithSpan }; 106 | -------------------------------------------------------------------------------- /serverless/src/utils/db.ts: -------------------------------------------------------------------------------- 1 | import { PokemonModel } from '../repositories/pokemon.sequelize.repository'; 2 | import { Sequelize } from 'sequelize-typescript'; 3 | import { SequelizeStorage, Umzug } from 'umzug'; 4 | 5 | export let sequelize: Sequelize | undefined = undefined; 6 | 7 | export async function setupSequelize() { 8 | const { DATABASE_URL = '' } = process.env; 9 | 10 | sequelize = new Sequelize(DATABASE_URL, { 11 | dialect: 'postgres', 12 | ssl: true, 13 | dialectOptions: { 14 | ssl: { 15 | require: true, 16 | rejectUnauthorized: false, 17 | }, 18 | }, 19 | }); 20 | 21 | sequelize.addModels([PokemonModel]); 22 | 23 | await runMigrations(sequelize); 24 | } 25 | 26 | async function runMigrations(sequelize) { 27 | const umzug = new Umzug({ 28 | migrations: { glob: './src/migrations/*.js' }, 29 | context: sequelize.getQueryInterface(), 30 | storage: new SequelizeStorage({ sequelize }), 31 | logger: console, 32 | }); 33 | 34 | await umzug.up(); 35 | } 36 | -------------------------------------------------------------------------------- /serverless/src/utils/traces.ts: -------------------------------------------------------------------------------- 1 | import * as opentelemetry from '@opentelemetry/api'; 2 | 3 | export const createSpanFromContext = async ( 4 | name: string, 5 | ctx: opentelemetry.Context, 6 | options?: opentelemetry.SpanOptions | undefined 7 | ): Promise => { 8 | const tracer = await opentelemetry.trace.getTracer('pokeshop-api'); 9 | 10 | if (!ctx) { 11 | return tracer.startSpan(name, options, opentelemetry.context.active()); 12 | } 13 | 14 | return tracer.startSpan(name, options, ctx); 15 | }; 16 | -------------------------------------------------------------------------------- /serverless/src/validators/createPokemon.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsString } from 'class-validator'; 2 | 3 | class CreatePokemon { 4 | @IsString() 5 | public name: string; 6 | 7 | @IsString() 8 | public type: string; 9 | 10 | @IsBoolean() 11 | public isFeatured: boolean; 12 | 13 | @IsString() 14 | public imageUrl: string; 15 | } 16 | 17 | export default CreatePokemon; 18 | -------------------------------------------------------------------------------- /serverless/src/validators/importPokemon.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsOptional } from 'class-validator'; 2 | 3 | class ImportPokemon { 4 | @IsNumber() 5 | @IsOptional() 6 | public id: number; 7 | } 8 | 9 | export default ImportPokemon; 10 | -------------------------------------------------------------------------------- /serverless/tracetest.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | import Tracetest from '@tracetest/client'; 3 | import { TestResource } from '@tracetest/client/dist/modules/openapi-client'; 4 | import { config } from 'dotenv'; 5 | 6 | config(); 7 | 8 | const { TRACETEST_API_TOKEN = '' } = process.env; 9 | const [raw = ''] = process.argv.slice(2); 10 | 11 | let url = ''; 12 | 13 | try { 14 | url = new URL(raw).origin; 15 | } catch (error) { 16 | console.error( 17 | 'The API Gateway URL is required as an argument. i.e: `npm test https://75yj353nn7.execute-api.us-east-1.amazonaws.com`' 18 | ); 19 | process.exit(1); 20 | } 21 | 22 | const definition: TestResource = { 23 | type: 'Test', 24 | spec: { 25 | id: 'ZV1G3v2IR', 26 | name: 'Serverless: Import Pokemon', 27 | trigger: { 28 | type: 'http', 29 | httpRequest: { 30 | method: 'POST', 31 | url: '${var:ENDPOINT}/import', 32 | body: '{"id": ${var:POKEMON_ID}}\n', 33 | headers: [ 34 | { 35 | key: 'Content-Type', 36 | value: 'application/json', 37 | }, 38 | ], 39 | }, 40 | }, 41 | specs: [ 42 | // Validates the external service is called with the proper POKEMON_ID and returns 200 43 | { 44 | selector: 'span[tracetest.span.type="http" name="GET" http.method="GET"]', 45 | name: 'External API service should return 200', 46 | assertions: ['attr:http.status_code = 200', 'attr:http.route = "/api/v2/pokemon/${var:POKEMON_ID}"'], 47 | }, 48 | // Validates the duration of the DB operations is less than 100ms 49 | { 50 | selector: 'span[tracetest.span.type="database"]', 51 | name: 'All Database Spans: Processing time is less than 100ms', 52 | assertions: ['attr:tracetest.span.duration < 100ms'], 53 | }, 54 | // Validates the response from the API Gateway is 200 55 | { 56 | selector: 'span[tracetest.span.type="general" name="Tracetest trigger"]', 57 | name: 'Initial request should return 200', 58 | assertions: ['attr:tracetest.response.status = 200'], 59 | }, 60 | ], 61 | }, 62 | }; 63 | 64 | const main = async () => { 65 | if (!url) 66 | throw new Error( 67 | 'The API Gateway URL is required as an argument. i.e: `npm test https://75yj353nn7.execute-api.us-east-1.amazonaws.com`' 68 | ); 69 | 70 | const tracetest = await Tracetest(TRACETEST_API_TOKEN); 71 | 72 | const test = await tracetest.newTest(definition); 73 | await tracetest.runTest(test, { 74 | variables: [ 75 | { 76 | key: 'ENDPOINT', 77 | value: `${url.trim()}/pokemon`, 78 | }, 79 | { 80 | key: 'POKEMON_ID', 81 | value: `${Math.floor(Math.random() * 100) + 1}`, 82 | }, 83 | ], 84 | }); 85 | console.log(await tracetest.getSummary()); 86 | }; 87 | 88 | main(); 89 | -------------------------------------------------------------------------------- /serverless/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "preserveConstEnums": true, 4 | "strictNullChecks": true, 5 | "sourceMap": true, 6 | "allowJs": true, 7 | "target": "esnext", 8 | "module": "commonjs", 9 | "outDir": ".build", 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "useDefineForClassFields": false, 13 | "lib": ["es2015"], 14 | "rootDir": "./" 15 | }, 16 | "exclude": ["tracetest.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /test/artillery/import-pokemon-definition.yml: -------------------------------------------------------------------------------- 1 | type: Test 2 | spec: 3 | id: artillery-plugin-import-pokemon 4 | name: "Artillery Plugin: Import a Pokemon" 5 | trigger: 6 | type: artillery 7 | specs: 8 | - selector: span[tracetest.span.type="http"] 9 | name: "All HTTP Spans: Status code is 200" 10 | assertions: 11 | - attr:http.status_code = 200 12 | - selector: span[tracetest.span.type="database"] 13 | name: "All Database Spans: Processing time is less than 1s" 14 | assertions: 15 | - attr:tracetest.span.duration < 1s 16 | - selector: span[tracetest.span.type="database" name="create pokeshop.pokemon" db.system="postgres" db.name="pokeshop" db.user="ashketchum" db.operation="create" db.sql.table="pokemon"] 17 | name: Pokemon should be added to db 18 | assertions: 19 | - attr:tracetest.selected_spans.count = 1 20 | -------------------------------------------------------------------------------- /test/artillery/import-pokemon.yml: -------------------------------------------------------------------------------- 1 | config: 2 | target: "http://localhost:8081" 3 | phases: 4 | - duration: 2 5 | arrivalRate: 2 6 | plugins: 7 | publish-metrics: 8 | - type: "open-telemetry" 9 | serviceName: "artillery" 10 | metrics: 11 | reporter: otlp-http 12 | endpoint: "http://localhost:4318/v1/metrics" 13 | attributes: 14 | environment: "test" 15 | tool: "Artillery" 16 | type: "Load test" 17 | 18 | traces: 19 | reporter: otlp-http 20 | endpoint: "http://localhost:4318/v1/traces" 21 | attributes: 22 | environment: "test" 23 | tool: "Artillery" 24 | w3c.tracestate: "tracetest=true" 25 | tracetest: 26 | token: VLpIlEeSg 27 | definition: ./test/artillery/import-pokemon-definition.yml 28 | serverUrl: https://app.tracetest.io/ 29 | runInfo: 30 | variables: 31 | - key: POKEMON_ID 32 | value: "88" 33 | scenarios: 34 | - name: "Import Pokemon" 35 | flow: 36 | - post: 37 | url: "/pokemon/import" 38 | json: 39 | id: 88 40 | -------------------------------------------------------------------------------- /test/k6/add-pokemon.js: -------------------------------------------------------------------------------- 1 | import { Http, Tracetest } from 'k6/x/tracetest'; 2 | import { sleep } from 'k6'; 3 | 4 | export const options = { 5 | vus: 2, 6 | duration: '5s', 7 | teardownTimeout: '2m', 8 | }; 9 | 10 | const POKESHOP_DEMO_URL = __ENV.POKESHOP_DEMO_URL || 'http://localhost:8081'; 11 | 12 | const http = new Http(); 13 | const tracetest = Tracetest(); 14 | 15 | export default function () { 16 | const url = `${POKESHOP_DEMO_URL}/pokemon`; 17 | const payload = JSON.stringify({ 18 | name: 'charizard', 19 | type: 'flying', 20 | imageUrl: 'https://assets.pokemon.com/assets/cms2/img/pokedex/full/006.png', 21 | isFeatured: false, 22 | }); 23 | const params = { 24 | headers: { 25 | 'Content-Type': 'application/json', 26 | }, 27 | }; 28 | 29 | const definition = `type: Test 30 | spec: 31 | id: k6-tracetest-pokeshop-add-pokemon 32 | name: "K6 - Add a Pokemon" 33 | trigger: 34 | type: k6 35 | specs: 36 | - selector: span[tracetest.span.type="http" name="POST /pokemon" http.method="POST"] 37 | name: The POST /pokemon was called correctly 38 | assertions: 39 | - attr:http.status_code = 201 40 | - selector: span[tracetest.span.type="general" name="validate request"] 41 | name: The request sent to API is valid 42 | assertions: 43 | - attr:validation.is_valid = "true" 44 | - selector: span[tracetest.span.type="database" name="create pokeshop.pokemon" db.system="postgres" db.name="pokeshop" db.user="ashketchum" db.operation="create" db.sql.table="pokemon"] 45 | name: A Pokemon was inserted on database 46 | assertions: 47 | - attr:db.result | json_path '$.imageUrl' = "https://assets.pokemon.com/assets/cms2/img/pokedex/full/006.png" 48 | `; 49 | 50 | const response = http.post(url, payload, params); 51 | 52 | tracetest.runTest( 53 | response.trace_id, 54 | { 55 | definition, 56 | should_wait: true, 57 | }, 58 | { 59 | url, 60 | method: 'GET', 61 | } 62 | ); 63 | 64 | sleep(1); 65 | } 66 | 67 | export function handleSummary() { 68 | return { 69 | stdout: tracetest.summary(), 70 | }; 71 | } 72 | 73 | export function teardown() { 74 | tracetest.validateResult(); 75 | } 76 | -------------------------------------------------------------------------------- /test/k6/import-pokemon.js: -------------------------------------------------------------------------------- 1 | import { Http, Tracetest } from 'k6/x/tracetest'; 2 | import { sleep } from 'k6'; 3 | 4 | export const options = { 5 | vus: 1, 6 | duration: '5s', 7 | teardownTimeout: '2m', 8 | }; 9 | 10 | const POKESHOP_DEMO_URL = __ENV.POKESHOP_DEMO_URL || 'http://localhost:8081'; 11 | 12 | const http = new Http(); 13 | const tracetest = Tracetest(); 14 | 15 | let pokemonId = 6; // charizard 16 | 17 | export default function () { 18 | const url = `${POKESHOP_DEMO_URL}/pokemon/import`; 19 | const payload = JSON.stringify({ 20 | id: pokemonId++, 21 | }); 22 | const params = { 23 | headers: { 24 | 'Content-Type': 'application/json', 25 | }, 26 | }; 27 | 28 | const definition = `type: Test 29 | spec: 30 | id: k6-tracetest-pokeshop-import-pokemon 31 | name: K6 32 | description: K6 33 | trigger: 34 | type: k6 35 | specs: 36 | - selector: span[tracetest.span.type="general" name="import pokemon"] 37 | name: Should have imported the pokemon 38 | assertions: 39 | - attr:tracetest.selected_spans.count = 1 40 | - selector: |- 41 | span[tracetest.span.type="http" net.peer.name="pokeapi.co" http.method="GET"] 42 | name: Should trigger a request to the POKEAPI 43 | assertions: 44 | - attr:http.url = "https://pokeapi.co/api/v2/pokemon/${pokemonId}" 45 | `; 46 | 47 | const response = http.post(url, payload, params); 48 | 49 | tracetest.runTest( 50 | response.trace_id, 51 | { 52 | definition, 53 | should_wait: true, 54 | }, 55 | { 56 | url, 57 | method: 'GET', 58 | } 59 | ); 60 | 61 | sleep(1); 62 | } 63 | 64 | export function handleSummary() { 65 | return { 66 | stdout: tracetest.summary(), 67 | }; 68 | } 69 | 70 | export function teardown() { 71 | tracetest.validateResult(); 72 | } 73 | -------------------------------------------------------------------------------- /test/k6/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | source .env 4 | 5 | TOKEN=$TRACETEST_API_TOKEN 6 | 7 | go install go.k6.io/xk6/cmd/xk6@latest 8 | xk6 build --with github.com/kubeshop/xk6-tracetest@latest 9 | ./k6 run test/k6/add-pokemon.js --env XK6_TRACETEST_API_TOKEN=$TOKEN -o xk6-tracetest 10 | -------------------------------------------------------------------------------- /tracetest/config/tracetest-cli.yaml: -------------------------------------------------------------------------------- 1 | scheme: http 2 | endpoint: tracetest-server:11633 3 | analyticsEnabled: false -------------------------------------------------------------------------------- /tracetest/config/tracetest-config.yaml: -------------------------------------------------------------------------------- 1 | postgres: 2 | host: tracetest-postgres 3 | user: postgres 4 | password: postgres 5 | port: 5432 6 | dbname: postgres 7 | params: sslmode=disable 8 | 9 | telemetry: 10 | exporters: 11 | collector: 12 | serviceName: tracetest 13 | sampling: 100 # 100% 14 | exporter: 15 | type: collector 16 | collector: 17 | endpoint: otel-collector:4317 18 | 19 | server: 20 | telemetry: 21 | exporter: collector 22 | applicationExporter: collector 23 | -------------------------------------------------------------------------------- /tracetest/config/tracetest-provision.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | type: PollingProfile 3 | spec: 4 | name: Default 5 | strategy: periodic 6 | default: true 7 | periodic: 8 | retryDelay: 5s 9 | timeout: 1m 10 | 11 | --- 12 | type: DataStore 13 | spec: 14 | name: jaeger 15 | type: jaeger 16 | jaeger: 17 | endpoint: jaeger:16685 18 | tls: 19 | insecure: true 20 | 21 | --- 22 | type: TestRunner 23 | spec: 24 | id: current 25 | name: default 26 | requiredGates: 27 | - analyzer-score 28 | - test-specs 29 | -------------------------------------------------------------------------------- /tracetest/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | tracebased-tests: 5 | image: kubeshop/tracetest:${TAG:-latest} 6 | volumes: 7 | - type: bind 8 | source: ./tracetest/tests 9 | target: /app/tracebased-tests 10 | - type: bind 11 | source: ./tracetest/config/tracetest-cli.yaml 12 | target: /app/tracetest-cli.yaml 13 | entrypoint: 14 | - tracetest 15 | command: 16 | - run 17 | - testsuite 18 | - --config 19 | - /app/tracetest-cli.yaml 20 | - --file 21 | - /app/tracebased-tests/testsuite.yaml 22 | depends_on: 23 | tracetest-server: 24 | condition: service_healthy 25 | 26 | tracetest-server: 27 | image: kubeshop/tracetest:${TAG:-latest} 28 | volumes: 29 | - type: bind 30 | source: ./tracetest/config/tracetest-config.yaml 31 | target: /app/tracetest.yaml 32 | - type: bind 33 | source: ./tracetest/config/tracetest-provision.yaml 34 | target: /app/provision.yaml 35 | command: --provisioning-file /app/provision.yaml 36 | ports: 37 | - 11633:11633 38 | extra_hosts: 39 | - "host.docker.internal:host-gateway" 40 | depends_on: 41 | tracetest-postgres: 42 | condition: service_healthy 43 | otel-collector: 44 | condition: service_started 45 | api: 46 | condition: service_healthy 47 | worker: 48 | condition: service_started 49 | streaming-worker: 50 | condition: service_started 51 | healthcheck: 52 | test: ["CMD", "wget", "--spider", "localhost:11633"] 53 | interval: 1s 54 | timeout: 3s 55 | retries: 60 56 | environment: 57 | TRACETEST_DEV: ${TRACETEST_DEV} 58 | 59 | tracetest-postgres: 60 | image: postgres:14 61 | environment: 62 | POSTGRES_PASSWORD: postgres 63 | POSTGRES_USER: postgres 64 | healthcheck: 65 | test: pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" 66 | interval: 1s 67 | timeout: 5s 68 | retries: 60 69 | -------------------------------------------------------------------------------- /tracetest/tests/add.yaml: -------------------------------------------------------------------------------- 1 | type: Test 2 | spec: 3 | id: BRkA3664R 4 | name: Pokeshop - Add 5 | description: Add a Pokemon 6 | trigger: 7 | type: http 8 | httpRequest: 9 | method: POST 10 | url: http://api:8081/pokemon 11 | body: | 12 | { 13 | "name": "meowth", 14 | "type":"normal", 15 | "imageUrl":"https://assets.pokemon.com/assets/cms2/img/pokedex/full/052.png", 16 | "isFeatured": false 17 | } 18 | headers: 19 | - key: Content-Type 20 | value: application/json 21 | specs: 22 | - selector: span[tracetest.span.type="http" name="POST /pokemon" http.method="POST"] 23 | name: The POST /pokemon was called correctly 24 | assertions: 25 | - attr:http.status_code = 201 26 | - selector: span[tracetest.span.type="general" name="validate request"] 27 | name: The request sent to API is valid 28 | assertions: 29 | - attr:validation.is_valid = "true" 30 | - selector: span[tracetest.span.type="database" name="create pokeshop.pokemon" db.system="postgres" db.name="pokeshop" db.user="ashketchum" db.operation="create" db.sql.table="pokemon"] 31 | name: A Pokemon was inserted on database 32 | assertions: 33 | - attr:db.result | json_path '$.imageUrl' = "https://assets.pokemon.com/assets/cms2/img/pokedex/full/052.png" 34 | -------------------------------------------------------------------------------- /tracetest/tests/importqueue.yaml: -------------------------------------------------------------------------------- 1 | type: Test 2 | spec: 3 | id: biH13ee4g 4 | name: Import a Pokemon using API and MQ Worker 5 | description: Import a Pokemon 6 | trigger: 7 | type: http 8 | httpRequest: 9 | method: POST 10 | url: http://api:8081/pokemon/import 11 | body: | 12 | { 13 | "id": 143 14 | } 15 | headers: 16 | - key: Content-Type 17 | value: application/json 18 | specs: 19 | - selector: span[tracetest.span.type="http" name="POST /pokemon/import" http.method="POST"] 20 | name: POST /pokemon/import was called successfuly 21 | assertions: 22 | - attr:http.status_code = 200 23 | - attr:http.response.body | json_path '$.id' = "143" 24 | - selector: span[tracetest.span.type="general" name="validate request"] 25 | name: The request was validated correctly 26 | assertions: 27 | - attr:validation.is_valid = "true" 28 | - selector: span[tracetest.span.type="messaging" name="queue.synchronizePokemon publish" messaging.system="rabbitmq" messaging.destination="queue.synchronizePokemon" messaging.operation="publish"] 29 | name: A message was enqueued to the worker 30 | assertions: 31 | - attr:messaging.payload | json_path '$.id' = "143" 32 | - selector: span[tracetest.span.type="messaging" name="queue.synchronizePokemon process" messaging.system="rabbitmq" messaging.destination="queue.synchronizePokemon" messaging.operation="process"] 33 | name: A message was read by the worker 34 | assertions: 35 | - attr:messaging.payload | json_path '$.fields.routingKey' = "queue.synchronizePokemon" 36 | - selector: span[tracetest.span.type="general" name="import pokemon"] 37 | name: A "import pokemon" action was triggered 38 | assertions: 39 | - attr:tracetest.selected_spans.count >= 1 40 | - selector: span[tracetest.span.type="http" name="GET" http.method="GET"] 41 | name: The PokeAPI was called with success 42 | assertions: 43 | - attr:http.status_code = 200 44 | -------------------------------------------------------------------------------- /tracetest/tests/importstream.yaml: -------------------------------------------------------------------------------- 1 | type: Test 2 | spec: 3 | id: a97syfdkjad 4 | name: Import a Pokemon reading a Stream 5 | description: Import a Pokemon via Stream 6 | trigger: 7 | type: kafka 8 | kafka: 9 | brokerUrls: 10 | - stream:9092 11 | topic: pokemon 12 | headers: [] 13 | messageKey: snorlax-key 14 | messageValue: "{\"id\":143}" 15 | specs: 16 | - selector: span[tracetest.span.type="messaging" name="pokemon process" messaging.system="kafka" messaging.destination="pokemon" messaging.destination_kind="topic" messaging.operation="process"] 17 | name: A message was received from Kafka stream 18 | assertions: 19 | - attr:messaging.system = "kafka" 20 | - selector: span[tracetest.span.type="general" name="import pokemon"] 21 | name: Import Pokemon use case was triggered 22 | assertions: 23 | - attr:name = "import pokemon" -------------------------------------------------------------------------------- /tracetest/tests/list.yaml: -------------------------------------------------------------------------------- 1 | type: Test 2 | spec: 3 | id: GAft3ee4g 4 | name: List Pokemons 5 | description: List Pokemons registered on Pokeshop API 6 | trigger: 7 | type: http 8 | httpRequest: 9 | method: GET 10 | url: http://api:8081/pokemon?take=20&skip=0 11 | headers: 12 | - key: Content-Type 13 | value: application/json 14 | specs: 15 | - selector: span[tracetest.span.type="http" name="GET /pokemon?take=20&skip=0" http.method="GET"] 16 | name: GET /pokemon endpoint was called and returned valid data 17 | assertions: 18 | - attr:http.response.body | json_path '$.items[*].imageUrl' contains "https://assets.pokemon.com/assets/cms2/img/pokedex/full/052.png" 19 | - attr:http.status_code = 200 20 | - attr:http.response.body | json_path '$.items[*].imageUrl' contains "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/143.png" 21 | - selector: span[tracetest.span.type="database" name="count pokeshop.pokemon" db.system="postgres" db.name="pokeshop" db.user="ashketchum" db.operation="count" db.sql.table="pokemon"] 22 | name: A count operation was triggered on database 23 | assertions: 24 | - attr:db.operation = "count" 25 | - selector: span[tracetest.span.type="database" name="findMany pokeshop.pokemon" db.system="postgres" db.name="pokeshop" db.user="ashketchum" db.operation="findMany" db.sql.table="pokemon"] 26 | name: A select operation was triggered on database 27 | assertions: 28 | - attr:db.operation = "findMany" 29 | -------------------------------------------------------------------------------- /tracetest/tests/testsuite.yaml: -------------------------------------------------------------------------------- 1 | type: Transaction 2 | spec: 3 | id: ILYjqDQ4g 4 | name: Running all tests 5 | description: Runs all tests in PokeAPI in sequence 6 | steps: 7 | - ./add.yaml 8 | - ./importqueue.yaml 9 | - ./importstream.yaml 10 | - ./list.yaml -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["es5", "dom"], 5 | "types": ["cypress", "node"] 6 | }, 7 | "include": ["./cypress/**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@opentelemetry/auto-instrumentations-web": "^0.41.0", 7 | "@opentelemetry/context-zone": "^1.26.0", 8 | "@opentelemetry/core": "^1.26.0", 9 | "@opentelemetry/exporter-trace-otlp-http": "^0.53.0", 10 | "@opentelemetry/instrumentation": "^0.53.0", 11 | "@opentelemetry/resources": "^1.26.0", 12 | "@opentelemetry/sdk-trace-base": "^1.26.0", 13 | "@opentelemetry/sdk-trace-web": "^1.26.0", 14 | "@opentelemetry/semantic-conventions": "^1.27.0", 15 | "@testing-library/jest-dom": "^5.16.4", 16 | "@testing-library/react": "^13.3.0", 17 | "@testing-library/user-event": "^13.5.0", 18 | "@tracetest/instrumentation-user-interaction": "^0.2.0", 19 | "@types/jest": "^27.5.2", 20 | "@types/node": "^16.11.43", 21 | "@types/react": "^18.0.15", 22 | "@types/react-dom": "^18.0.6", 23 | "antd": "^4.21.6", 24 | "lodash": "^4.17.21", 25 | "react": "^18.2.0", 26 | "react-dom": "^18.2.0", 27 | "react-query": "^3.39.1", 28 | "react-router-dom": "^6.3.0", 29 | "react-scripts": "5.0.1", 30 | "styled-components": "^5.3.5", 31 | "web-vitals": "^2.1.4" 32 | }, 33 | "scripts": { 34 | "start": "react-scripts start", 35 | "build": "react-scripts build", 36 | "test": "react-scripts test", 37 | "eject": "react-scripts eject", 38 | "types:generate": "openapi-typescript ../openapi/openapi.yaml --output ./src/types/generated.ts" 39 | }, 40 | "eslintConfig": { 41 | "extends": [ 42 | "react-app", 43 | "react-app/jest" 44 | ] 45 | }, 46 | "browserslist": { 47 | "production": [ 48 | ">0.2%", 49 | "not dead", 50 | "not op_mini all" 51 | ], 52 | "development": [ 53 | "last 1 chrome version", 54 | "last 1 firefox version", 55 | "last 1 safari version" 56 | ] 57 | }, 58 | "resolutions": { 59 | "styled-components": "5" 60 | }, 61 | "devDependencies": { 62 | "@types/lodash": "^4.14.197", 63 | "@types/styled-components": "^5.1.25", 64 | "openapi-typescript": "^5.4.1" 65 | }, 66 | "proxy": "http://localhost:8081" 67 | } 68 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kubeshop/pokeshop/3be5812abce5efaedf426c29b11da5b0b5b3ea64/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 24 | Pokeshop 25 | 26 | 27 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /web/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /web/src/App.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100vh; 4 | width: 100vw; 5 | overflow-x: hidden; 6 | } 7 | 8 | #root { 9 | display: flex; 10 | flex-direction: column; 11 | flex-grow: 1; 12 | } 13 | 14 | body { 15 | margin: 0; 16 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 17 | 'Droid Sans', 'Helvetica Neue', sans-serif; 18 | -webkit-font-smoothing: antialiased; 19 | -moz-osx-font-smoothing: grayscale; 20 | display: flex; 21 | } 22 | -------------------------------------------------------------------------------- /web/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from 'styled-components'; 2 | import { QueryClient, QueryClientProvider } from 'react-query'; 3 | import './utils/tracer'; 4 | import Router from './components/Router'; 5 | import { theme } from './constants/theme'; 6 | 7 | import 'antd/dist/antd.css'; 8 | import 'App.css'; 9 | 10 | const queryClient = new QueryClient(); 11 | 12 | function App() { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | 22 | export default App; 23 | -------------------------------------------------------------------------------- /web/src/components/CreateModal/CreateModal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, Form, Input, Switch } from 'antd'; 2 | import { TCreatePokemon } from '../../types/pokemon'; 3 | 4 | type TCreteValues = Required; 5 | 6 | interface IProps { 7 | isOpen: boolean; 8 | onCreate(values: TCreteValues): void; 9 | onClose(): void; 10 | } 11 | 12 | const CreateModal = ({ isOpen, onCreate, onClose }: IProps) => { 13 | const [form] = Form.useForm(); 14 | const handleSubmit = (values: TCreteValues) => { 15 | onClose(); 16 | onCreate(values); 17 | }; 18 | 19 | return ( 20 | form.submit()} onCancel={onClose}> 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 42 | 43 | 44 |
45 |
46 | ); 47 | }; 48 | 49 | export default CreateModal; 50 | -------------------------------------------------------------------------------- /web/src/components/CreateModal/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-exports 2 | export { default } from './CreateModal'; 3 | -------------------------------------------------------------------------------- /web/src/components/Header/Header.styled.tsx: -------------------------------------------------------------------------------- 1 | import { Layout, Menu } from 'antd'; 2 | import styled from 'styled-components'; 3 | 4 | export const Header = styled(Layout.Header)` 5 | display: flex; 6 | justify-content: space-between; 7 | align-items: center; 8 | padding: 0 24px; 9 | background: ${({ theme }) => theme.color.white}; 10 | border-bottom: ${({ theme }) => `1px solid ${theme.color.borderLight}`}; 11 | 12 | .ant-popover-title { 13 | padding: 16px; 14 | } 15 | .ant-popover-arrow { 16 | display: none; 17 | } 18 | .ant-popover-inner-content { 19 | padding: 0; 20 | } 21 | `; 22 | 23 | export const NavMenu = styled(Menu).attrs({ 24 | mode: 'horizontal', 25 | disabledOverflow: true, 26 | })` 27 | && { 28 | align-items: center; 29 | } 30 | 31 | .ant-menu-item > span > a { 32 | color: ${({ theme }) => theme.color.primary}; 33 | } 34 | `; 35 | -------------------------------------------------------------------------------- /web/src/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import Logo from '../../assets/Logo.svg'; 3 | import { Link, useLocation } from 'react-router-dom'; 4 | import * as S from './Header.styled'; 5 | import { HeaderMenu } from './HeaderMenu'; 6 | 7 | const Header: FC = () => { 8 | const { pathname } = useLocation(); 9 | 10 | return ( 11 | 12 | 13 | tracetest_log 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default Header; 21 | -------------------------------------------------------------------------------- /web/src/components/Header/HeaderMenu.tsx: -------------------------------------------------------------------------------- 1 | import { DOCUMENTATION_URL, GITHUB_URL, COMMUNITY_SLACK_URL } from '../../constants/common'; 2 | import * as S from './Header.styled'; 3 | 4 | interface IProps { 5 | pathname: string; 6 | } 7 | 8 | export const HeaderMenu = ({ pathname }: IProps) => { 9 | return ( 10 | 17 | GitHub 18 | 19 | ), 20 | }, 21 | { 22 | key: 'docs', 23 | label: ( 24 | 25 | Documentation 26 | 27 | ), 28 | }, 29 | { 30 | key: 'slack', 31 | label: ( 32 | 33 | Slack 34 | 35 | ), 36 | }, 37 | ]} 38 | /> 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /web/src/components/Header/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-exports 2 | export {default} from './Header'; 3 | -------------------------------------------------------------------------------- /web/src/components/ImportModal/ImportModal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, Form, Input } from 'antd'; 2 | 3 | interface IImportValues { 4 | id: number; 5 | } 6 | 7 | interface IProps { 8 | isOpen: boolean; 9 | onImport(values: IImportValues): void; 10 | onClose(): void; 11 | } 12 | 13 | const ImportModal = ({ isOpen, onImport, onClose }: IProps) => { 14 | const [form] = Form.useForm(); 15 | const handleSubmit = (values: IImportValues) => { 16 | onClose(); 17 | onImport(values); 18 | }; 19 | 20 | return ( 21 | form.submit()} onCancel={onClose}> 22 |
23 | 24 | 25 | 26 |
27 |
28 | ); 29 | }; 30 | 31 | export default ImportModal; 32 | -------------------------------------------------------------------------------- /web/src/components/ImportModal/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-exports 2 | export { default } from './ImportModal'; 3 | -------------------------------------------------------------------------------- /web/src/components/Layout/Layout.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Layout from 'antd/lib/layout'; 3 | 4 | export const Content = styled(Layout.Content)` 5 | background: ${({theme}) => theme.color.background}; 6 | display: flex; 7 | flex-direction: column; 8 | flex-grow: 1; 9 | `; 10 | -------------------------------------------------------------------------------- /web/src/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import * as S from './Layout.styled'; 2 | import Header from '../Header'; 3 | 4 | interface IProps { 5 | children: React.ReactNode; 6 | } 7 | 8 | const Layout = ({children}: IProps) => { 9 | return ( 10 | <> 11 |
12 | {children} 13 | 14 | ); 15 | }; 16 | 17 | export default Layout; 18 | -------------------------------------------------------------------------------- /web/src/components/Layout/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-exports 2 | export {default} from './Layout'; 3 | -------------------------------------------------------------------------------- /web/src/components/PokemonCard/PokemonCard.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const CardCover = styled.div` 4 | padding: 24px; 5 | width: 100%; 6 | 7 | img { 8 | max-width: 100%; 9 | width: 100%; 10 | height: auto; 11 | } 12 | `; 13 | 14 | export const DeleteContainer = styled.div` 15 | position: absolute; 16 | top: -10px; 17 | right: -10px; 18 | `; -------------------------------------------------------------------------------- /web/src/components/PokemonCard/PokemonCard.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Button } from 'antd'; 2 | import { CloseOutlined } from '@ant-design/icons'; 3 | import { TPokemon } from '../../types/pokemon'; 4 | import * as S from './PokemonCard.styled'; 5 | import usePokemonCrud from '../../hooks/usePokemonCrud'; 6 | 7 | interface IProps { 8 | pokemon: TPokemon; 9 | isFeaturedList: boolean; 10 | } 11 | 12 | const PokemonCard = ({ pokemon: { id = 0, name = '', type = '', imageUrl = '' }, isFeaturedList }: IProps) => { 13 | const { deletePokemon } = usePokemonCrud(); 14 | 15 | return ( 16 | 22 | {!isFeaturedList && ( 23 | 24 |