diff --git a/.github/workflows/opensearch.yml b/.github/workflows/opensearch.yml new file mode 100644 index 0000000000..8e21fcbaba --- /dev/null +++ b/.github/workflows/opensearch.yml @@ -0,0 +1,45 @@ +name: OpenSearch +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + build: + runs-on: ubuntu-latest + if: ${{ !contains(github.event.head_commit.message, 'docs:') }} + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + - name: Install sitespeed.io + run: npm ci + - name: Install Chrome + run: | + wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - + sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' + sudo apt-get update + sudo apt-get --only-upgrade install google-chrome-stable + google-chrome --version + - name: Install local HTTP server + run: npm install serve -g + - name: Start local HTTP server + run: (serve test/data/html/ -l 3001&) + - name: Start OpenSearch + run: docker compose -f test/docker/docker-compose.opensearch.yml up -d + - name: Wait for OpenSearch + run: | + timeout 90 bash -c \ + 'until curl -sf http://localhost:9200/_cluster/health | grep -q "\"status\":\"green\"\|\"status\":\"yellow\""; do + echo "Waiting for OpenSearch..."; sleep 3 + done' + - name: Apply OpenSearch index templates + run: docker/opensearch/setup.sh http://localhost:9200 + - name: Run test with OpenSearch + run: bin/sitespeed.js http://127.0.0.1:3001/simple/ -n 1 --opensearch.host 127.0.0.1 --xvfb + - name: Run test with OpenSearch including runs + run: bin/sitespeed.js http://127.0.0.1:3001/simple/ -n 1 --opensearch.host 127.0.0.1 --opensearch.includeRuns --xvfb diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 89b3bc7bde..e1b722542d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,10 +1,10 @@ -version: '3' services: grafana: image: grafana/grafana:12.2.0 hostname: grafana depends_on: - graphite + - opensearch links: - graphite ports: @@ -16,7 +16,7 @@ services: - GF_AUTH_ANONYMOUS_ENABLED=true - GF_USERS_ALLOW_SIGN_UP=false - GF_USERS_ALLOW_ORG_CREATE=false - - GF_INSTALL_PLUGINS=grafana-piechart-panel,marcusolsson-json-datasource,marcusolsson-dynamictext-panel + - GF_INSTALL_PLUGINS=grafana-piechart-panel,marcusolsson-json-datasource,marcusolsson-dynamictext-panel,grafana-opensearch-datasource - GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/Welcome.json volumes: - grafana:/var/lib/grafana @@ -38,12 +38,47 @@ services: - whisper:/opt/graphite/storage/whisper # Download an empty graphite.db from https://github.com/sitespeedio/sitespeed.io/tree/main/docker/graphite # - /absolute/path/to/graphite/graphite.db:/opt/graphite/storage/graphite.db - # + # # And put the configuration files on your server, configure them as you need # Download from https://github.com/sitespeedio/docker-graphite-statsd/tree/main/conf/graphite # - /absolute/path/to/graphite/conf/storage-schemas.conf:/opt/graphite/conf/storage-schemas.conf # - /absolute/path/to/graphite/conf/storage-aggregation.conf:/opt/graphite/conf/storage-aggregation.conf # - /absolute/path/to/graphite/conf/carbon.conf:/opt/graphite/conf/carbon.conf + opensearch: + image: opensearchproject/opensearch:2 + hostname: opensearch + environment: + - discovery.type=single-node + - DISABLE_SECURITY_PLUGIN=true + - OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m + ports: + - "9200:9200" + volumes: + - opensearch:/usr/share/opensearch/data + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:9200/_cluster/health"] + interval: 10s + timeout: 5s + retries: 20 + restart: always + opensearch-setup: + image: curlimages/curl:latest + depends_on: + opensearch: + condition: service_healthy + volumes: + - ../docker/opensearch:/setup:ro + entrypoint: /bin/sh + command: + - -c + - | + for t in pagesummary run pagexray compare coach; do + curl -sf -X PUT "http://opensearch:9200/_index_template/sitespeed-$$t" \ + -H 'Content-Type: application/json' \ + -d "@/setup/index-template-$$t.json" && echo "Applied index template: sitespeed-$$t" + done + restart: "no" volumes: grafana: whisper: + opensearch: diff --git a/docker/grafana/provisioning/dashboards/OpenSearch.json b/docker/grafana/provisioning/dashboards/OpenSearch.json new file mode 100644 index 0000000000..a59a6f281b --- /dev/null +++ b/docker/grafana/provisioning/dashboards/OpenSearch.json @@ -0,0 +1,501 @@ +{ + "annotations": { "list": [] }, + "description": "sitespeed.io page summary metrics stored in OpenSearch. Shows timings, Core Web Vitals, and visual metrics per URL.", + "editable": true, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 100, + "title": "Core Web Vitals", + "type": "row" + }, + { + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 2, "fillOpacity": 10 }, + "unit": "ms", + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 2500 }, + { "color": "red", "value": 4000 } + ] + } + } + }, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 1 }, + "id": 1, + "options": { "tooltip": { "mode": "multi", "sort": "none" } }, + "targets": [ + { + "alias": "LCP median", + "bucketAggs": [ + { "field": "@timestamp", "id": "2", "settings": { "interval": "auto", "min_doc_count": "1" }, "type": "date_histogram" } + ], + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "metrics": [{ "field": "timings.lcp.median", "id": "1", "settings": {}, "type": "avg" }], + "query": "url:\"$url\" AND browser:\"$browser\" AND connectivity:\"$connectivity\" AND deviceType:\"$deviceType\"", + "refId": "A", + "timeField": "@timestamp" + }, + { + "alias": "LCP p90", + "bucketAggs": [ + { "field": "@timestamp", "id": "4", "settings": { "interval": "auto", "min_doc_count": "1" }, "type": "date_histogram" } + ], + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "metrics": [{ "field": "timings.lcp.p90", "id": "3", "settings": {}, "type": "avg" }], + "query": "url:\"$url\" AND browser:\"$browser\" AND connectivity:\"$connectivity\" AND deviceType:\"$deviceType\"", + "refId": "B", + "timeField": "@timestamp" + } + ], + "title": "Largest Contentful Paint (LCP)", + "type": "timeseries" + }, + { + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 2, "fillOpacity": 10 }, + "unit": "short", + "min": 0, + "decimals": 3, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.1 }, + { "color": "red", "value": 0.25 } + ] + } + } + }, + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 1 }, + "id": 2, + "options": { "tooltip": { "mode": "multi", "sort": "none" } }, + "targets": [ + { + "alias": "CLS median", + "bucketAggs": [ + { "field": "@timestamp", "id": "2", "settings": { "interval": "auto", "min_doc_count": "1" }, "type": "date_histogram" } + ], + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "metrics": [{ "field": "googleWebVitals.cls.median", "id": "1", "settings": {}, "type": "avg" }], + "query": "url:\"$url\" AND browser:\"$browser\" AND connectivity:\"$connectivity\" AND deviceType:\"$deviceType\"", + "refId": "A", + "timeField": "@timestamp" + } + ], + "title": "Cumulative Layout Shift (CLS)", + "type": "timeseries" + }, + { + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 2, "fillOpacity": 10 }, + "unit": "ms", + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 200 }, + { "color": "red", "value": 500 } + ] + } + } + }, + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 1 }, + "id": 3, + "options": { "tooltip": { "mode": "multi", "sort": "none" } }, + "targets": [ + { + "alias": "INP median", + "bucketAggs": [ + { "field": "@timestamp", "id": "2", "settings": { "interval": "auto", "min_doc_count": "1" }, "type": "date_histogram" } + ], + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "metrics": [{ "field": "googleWebVitals.inp.median", "id": "1", "settings": {}, "type": "avg" }], + "query": "url:\"$url\" AND browser:\"$browser\" AND connectivity:\"$connectivity\" AND deviceType:\"$deviceType\"", + "refId": "A", + "timeField": "@timestamp" + } + ], + "title": "Interaction to Next Paint (INP)", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 9 }, + "id": 101, + "title": "Timings", + "type": "row" + }, + { + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 2, "fillOpacity": 10 }, + "unit": "ms", + "min": 0 + } + }, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 10 }, + "id": 4, + "options": { "tooltip": { "mode": "multi", "sort": "none" } }, + "targets": [ + { + "alias": "TTFB median", + "bucketAggs": [ + { "field": "@timestamp", "id": "2", "settings": { "interval": "auto", "min_doc_count": "1" }, "type": "date_histogram" } + ], + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "metrics": [{ "field": "timings.ttfb.median", "id": "1", "settings": {}, "type": "avg" }], + "query": "url:\"$url\" AND browser:\"$browser\" AND connectivity:\"$connectivity\" AND deviceType:\"$deviceType\"", + "refId": "A", + "timeField": "@timestamp" + }, + { + "alias": "TTFB p90", + "bucketAggs": [ + { "field": "@timestamp", "id": "4", "settings": { "interval": "auto", "min_doc_count": "1" }, "type": "date_histogram" } + ], + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "metrics": [{ "field": "timings.ttfb.p90", "id": "3", "settings": {}, "type": "avg" }], + "query": "url:\"$url\" AND browser:\"$browser\" AND connectivity:\"$connectivity\" AND deviceType:\"$deviceType\"", + "refId": "B", + "timeField": "@timestamp" + } + ], + "title": "Time to First Byte (TTFB)", + "type": "timeseries" + }, + { + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 2, "fillOpacity": 10 }, + "unit": "ms", + "min": 0 + } + }, + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 10 }, + "id": 5, + "options": { "tooltip": { "mode": "multi", "sort": "none" } }, + "targets": [ + { + "alias": "FCP median", + "bucketAggs": [ + { "field": "@timestamp", "id": "2", "settings": { "interval": "auto", "min_doc_count": "1" }, "type": "date_histogram" } + ], + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "metrics": [{ "field": "timings.fcp.median", "id": "1", "settings": {}, "type": "avg" }], + "query": "url:\"$url\" AND browser:\"$browser\" AND connectivity:\"$connectivity\" AND deviceType:\"$deviceType\"", + "refId": "A", + "timeField": "@timestamp" + } + ], + "title": "First Contentful Paint (FCP)", + "type": "timeseries" + }, + { + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 2, "fillOpacity": 10 }, + "unit": "ms", + "min": 0 + } + }, + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 10 }, + "id": 6, + "options": { "tooltip": { "mode": "multi", "sort": "none" } }, + "targets": [ + { + "alias": "Fully Loaded median", + "bucketAggs": [ + { "field": "@timestamp", "id": "2", "settings": { "interval": "auto", "min_doc_count": "1" }, "type": "date_histogram" } + ], + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "metrics": [{ "field": "timings.fullyLoaded.median", "id": "1", "settings": {}, "type": "avg" }], + "query": "url:\"$url\" AND browser:\"$browser\" AND connectivity:\"$connectivity\" AND deviceType:\"$deviceType\"", + "refId": "A", + "timeField": "@timestamp" + }, + { + "alias": "Fully Loaded p90", + "bucketAggs": [ + { "field": "@timestamp", "id": "4", "settings": { "interval": "auto", "min_doc_count": "1" }, "type": "date_histogram" } + ], + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "metrics": [{ "field": "timings.fullyLoaded.p90", "id": "3", "settings": {}, "type": "avg" }], + "query": "url:\"$url\" AND browser:\"$browser\" AND connectivity:\"$connectivity\" AND deviceType:\"$deviceType\"", + "refId": "B", + "timeField": "@timestamp" + } + ], + "title": "Fully Loaded", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 18 }, + "id": 102, + "title": "Visual Metrics (requires video)", + "type": "row" + }, + { + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 2, "fillOpacity": 10 }, + "unit": "ms", + "min": 0 + } + }, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 19 }, + "id": 7, + "options": { "tooltip": { "mode": "multi", "sort": "none" } }, + "targets": [ + { + "alias": "Speed Index median", + "bucketAggs": [ + { "field": "@timestamp", "id": "2", "settings": { "interval": "auto", "min_doc_count": "1" }, "type": "date_histogram" } + ], + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "metrics": [{ "field": "visualMetrics.speedIndex.median", "id": "1", "settings": {}, "type": "avg" }], + "query": "url:\"$url\" AND browser:\"$browser\" AND connectivity:\"$connectivity\" AND deviceType:\"$deviceType\"", + "refId": "A", + "timeField": "@timestamp" + } + ], + "title": "Speed Index", + "type": "timeseries" + }, + { + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 2, "fillOpacity": 10 }, + "unit": "ms", + "min": 0 + } + }, + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 19 }, + "id": 8, + "options": { "tooltip": { "mode": "multi", "sort": "none" } }, + "targets": [ + { + "alias": "First Visual Change median", + "bucketAggs": [ + { "field": "@timestamp", "id": "2", "settings": { "interval": "auto", "min_doc_count": "1" }, "type": "date_histogram" } + ], + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "metrics": [{ "field": "visualMetrics.firstVisualChange.median", "id": "1", "settings": {}, "type": "avg" }], + "query": "url:\"$url\" AND browser:\"$browser\" AND connectivity:\"$connectivity\" AND deviceType:\"$deviceType\"", + "refId": "A", + "timeField": "@timestamp" + }, + { + "alias": "Last Visual Change median", + "bucketAggs": [ + { "field": "@timestamp", "id": "4", "settings": { "interval": "auto", "min_doc_count": "1" }, "type": "date_histogram" } + ], + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "metrics": [{ "field": "visualMetrics.lastVisualChange.median", "id": "3", "settings": {}, "type": "avg" }], + "query": "url:\"$url\" AND browser:\"$browser\" AND connectivity:\"$connectivity\" AND deviceType:\"$deviceType\"", + "refId": "B", + "timeField": "@timestamp" + } + ], + "title": "Visual Change", + "type": "timeseries" + }, + { + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 2, "fillOpacity": 10 }, + "unit": "ms", + "min": 0 + } + }, + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 19 }, + "id": 9, + "options": { "tooltip": { "mode": "multi", "sort": "none" } }, + "targets": [ + { + "alias": "Perceptual Speed Index median", + "bucketAggs": [ + { "field": "@timestamp", "id": "2", "settings": { "interval": "auto", "min_doc_count": "1" }, "type": "date_histogram" } + ], + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "metrics": [{ "field": "visualMetrics.perceptualSpeedIndex.median", "id": "1", "settings": {}, "type": "avg" }], + "query": "url:\"$url\" AND browser:\"$browser\" AND connectivity:\"$connectivity\" AND deviceType:\"$deviceType\"", + "refId": "A", + "timeField": "@timestamp" + } + ], + "title": "Perceptual Speed Index", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 27 }, + "id": 103, + "title": "Page Weight", + "type": "row" + }, + { + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 2, "fillOpacity": 10 }, + "unit": "decbytes", + "min": 0 + } + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 28 }, + "id": 10, + "options": { "tooltip": { "mode": "multi", "sort": "none" } }, + "targets": [ + { + "alias": "Transfer size median", + "bucketAggs": [ + { "field": "@timestamp", "id": "2", "settings": { "interval": "auto", "min_doc_count": "1" }, "type": "date_histogram" } + ], + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "metrics": [{ "field": "transferSize.median", "id": "1", "settings": {}, "type": "avg" }], + "query": "url:\"$url\" AND browser:\"$browser\" AND connectivity:\"$connectivity\" AND deviceType:\"$deviceType\"", + "refId": "A", + "timeField": "@timestamp", + "index": "sitespeed-pagexray-*" + } + ], + "title": "Transfer Size", + "type": "timeseries" + }, + { + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 2, "fillOpacity": 10 }, + "unit": "short", + "min": 0, + "decimals": 0 + } + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 28 }, + "id": 11, + "options": { "tooltip": { "mode": "multi", "sort": "none" } }, + "targets": [ + { + "alias": "Requests median", + "bucketAggs": [ + { "field": "@timestamp", "id": "2", "settings": { "interval": "auto", "min_doc_count": "1" }, "type": "date_histogram" } + ], + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "metrics": [{ "field": "requests.median", "id": "1", "settings": {}, "type": "avg" }], + "query": "url:\"$url\" AND browser:\"$browser\" AND connectivity:\"$connectivity\" AND deviceType:\"$deviceType\"", + "refId": "A", + "timeField": "@timestamp", + "index": "sitespeed-pagexray-*" + } + ], + "title": "Requests", + "type": "timeseries" + } + ], + "schemaVersion": 38, + "tags": ["sitespeed.io", "opensearch", "performance"], + "templating": { + "list": [ + { + "current": {}, + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "definition": "{\"find\": \"terms\", \"field\": \"url\"}", + "hide": 0, + "includeAll": false, + "label": "URL", + "multi": false, + "name": "url", + "options": [], + "query": "{\"find\": \"terms\", \"field\": \"url\"}", + "refresh": 2, + "sort": 1, + "type": "query" + }, + { + "current": {}, + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "definition": "{\"find\": \"terms\", \"field\": \"browser\"}", + "hide": 0, + "includeAll": true, + "label": "Browser", + "multi": false, + "name": "browser", + "options": [], + "query": "{\"find\": \"terms\", \"field\": \"browser\"}", + "refresh": 2, + "sort": 1, + "type": "query" + }, + { + "current": {}, + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "definition": "{\"find\": \"terms\", \"field\": \"connectivity\"}", + "hide": 0, + "includeAll": true, + "label": "Connectivity", + "multi": false, + "name": "connectivity", + "options": [], + "query": "{\"find\": \"terms\", \"field\": \"connectivity\"}", + "refresh": 2, + "sort": 1, + "type": "query" + }, + { + "current": {}, + "datasource": { "type": "grafana-opensearch-datasource", "uid": "sitespeed-opensearch" }, + "definition": "{\"find\": \"terms\", \"field\": \"deviceType\"}", + "hide": 0, + "includeAll": true, + "label": "Device type", + "multi": false, + "name": "deviceType", + "options": [], + "query": "{\"find\": \"terms\", \"field\": \"deviceType\"}", + "refresh": 2, + "sort": 1, + "type": "query" + } + ] + }, + "time": { "from": "now-7d", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "sitespeed.io Page Summary (OpenSearch)", + "uid": "sitespeed-opensearch-pagesummary", + "version": 1 +} diff --git a/docker/grafana/provisioning/datasources/opensearch.yaml b/docker/grafana/provisioning/datasources/opensearch.yaml new file mode 100644 index 0000000000..a3762335fb --- /dev/null +++ b/docker/grafana/provisioning/datasources/opensearch.yaml @@ -0,0 +1,19 @@ +apiVersion: 1 + +datasources: + - name: OpenSearch + type: grafana-opensearch-datasource + access: proxy + orgId: 1 + uid: sitespeed-opensearch + url: http://opensearch:9200 + isDefault: false + jsonData: + database: sitespeed-pagesummary-* + timeField: "@timestamp" + version: "2.0.0" + flavor: opensearch + logMessageField: message + logLevelField: level + version: 1 + editable: true diff --git a/docker/opensearch/docker-compose.yml b/docker/opensearch/docker-compose.yml new file mode 100644 index 0000000000..840176179c --- /dev/null +++ b/docker/opensearch/docker-compose.yml @@ -0,0 +1,15 @@ +services: + opensearch: + image: opensearchproject/opensearch:2 + environment: + - discovery.type=single-node + - DISABLE_SECURITY_PLUGIN=true + - OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m + ports: + - "9200:9200" + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:9200/_cluster/health"] + interval: 5s + timeout: 5s + retries: 20 + diff --git a/docker/opensearch/index-template-coach.json b/docker/opensearch/index-template-coach.json new file mode 100644 index 0000000000..4cd0ea0139 --- /dev/null +++ b/docker/opensearch/index-template-coach.json @@ -0,0 +1,41 @@ +{ + "index_patterns": ["sitespeed-coach-*"], + "template": { + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0, + "plugins.index_state_management.policy_id": "sitespeed-default-retention" + }, + "mappings": { + "dynamic": "strict", + "properties": { + "@timestamp": { "type": "date" }, + "url": { "type": "keyword" }, + "group": { "type": "keyword" }, + "alias": { "type": "keyword" }, + "browser": { "type": "keyword" }, + "connectivity":{ "type": "keyword" }, + "slug": { "type": "keyword" }, + "type": { "type": "keyword" }, + "resultUrl": { "type": "keyword" }, + "deviceType": { "type": "keyword" }, + "iterations": { "type": "integer" }, + "name": { "type": "keyword" }, + "score": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "performance": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "privacy": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "bestpractice": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "info": { + "properties": { + "documentHeight": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "domElements": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "domDepth": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "iframes": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "scripts": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "localStorageSize":{ "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } } + } + } + } + } + } +} diff --git a/docker/opensearch/index-template-compare.json b/docker/opensearch/index-template-compare.json new file mode 100644 index 0000000000..3cd838b3c2 --- /dev/null +++ b/docker/opensearch/index-template-compare.json @@ -0,0 +1,72 @@ +{ + "index_patterns": ["sitespeed-compare-*"], + "template": { + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0, + "plugins.index_state_management.policy_id": "sitespeed-default-retention" + }, + "mappings": { + "dynamic": "strict", + "properties": { + "@timestamp": { "type": "date" }, + "url": { "type": "keyword" }, + "group": { "type": "keyword" }, + "alias": { "type": "keyword" }, + "browser": { "type": "keyword" }, + "connectivity": { "type": "keyword" }, + "slug": { "type": "keyword" }, + "type": { "type": "keyword" }, + "resultUrl": { "type": "keyword" }, + "deviceType": { "type": "keyword" }, + "iterations": { "type": "integer" }, + "name": { "type": "keyword" }, + "baselineTimestamp": { "type": "keyword" }, + "anySignificant": { "type": "boolean" }, + "significantMetrics":{ "type": "keyword" }, + "timings": { + "properties": { + "ttfb": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "fcp": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "fullyLoaded": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "loadEventEnd":{ "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } } + } + }, + "visualMetrics": { + "properties": { + "speedIndex": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "perceptualSpeedIndex": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "firstVisualChange": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "lastVisualChange": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "contentfulSpeedIndex": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } } + } + }, + "googleWebVitals": { + "properties": { + "lcp": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "cls": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "inp": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "fid": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } } + } + }, + "cpu": { + "properties": { + "totalDuration": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "tasks": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "lastLongTask": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "beforeFCP": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "beforeLCP": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } } + } + }, + "renderBlocking": { + "properties": { + "beforeFCPms": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "beforeLCPms": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "beforeFCPelements": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } }, + "beforeLCPelements": { "properties": { "isSignificant": { "type": "boolean" }, "cliffsDelta": { "type": "float" }, "pValue": { "type": "float" }, "currentMedian": { "type": "float" }, "baselineMedian": { "type": "float" } } } + } + } + } + } + } +} diff --git a/docker/opensearch/index-template-pagesummary.json b/docker/opensearch/index-template-pagesummary.json new file mode 100644 index 0000000000..a09c92d8f7 --- /dev/null +++ b/docker/opensearch/index-template-pagesummary.json @@ -0,0 +1,69 @@ +{ + "index_patterns": ["sitespeed-pagesummary-*"], + "template": { + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0, + "plugins.index_state_management.policy_id": "sitespeed-default-retention" + }, + "mappings": { + "dynamic": "strict", + "properties": { + "@timestamp": { "type": "date" }, + "url": { "type": "keyword" }, + "group": { "type": "keyword" }, + "alias": { "type": "keyword" }, + "browser": { "type": "keyword" }, + "connectivity":{ "type": "keyword" }, + "slug": { "type": "keyword" }, + "type": { "type": "keyword" }, + "resultUrl": { "type": "keyword" }, + "deviceType": { "type": "keyword" }, + "iterations": { "type": "integer" }, + "name": { "type": "keyword" }, + "timings": { + "properties": { + "firstPaint": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "fcp": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "lcp": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "ttfb": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "fullyLoaded": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "loadEventEnd": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "timeToInteractive":{ "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } } + } + }, + "visualMetrics": { + "properties": { + "speedIndex": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "perceptualSpeedIndex": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "firstVisualChange": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "lastVisualChange": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "visualComplete85": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "visualComplete95": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "visualComplete99": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } } + } + }, + "googleWebVitals": { + "properties": { + "lcp": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "cls": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "inp": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "fid": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } } + } + }, + "cpu": { + "properties": { + "longTasksTotalDuration": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "longTasksCount": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } } + } + }, + "pageInfo": { + "properties": { + "cumulativeLayoutShift": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "domElements": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } } + } + } + } + } + } +} diff --git a/docker/opensearch/index-template-pagexray.json b/docker/opensearch/index-template-pagexray.json new file mode 100644 index 0000000000..59d69837ed --- /dev/null +++ b/docker/opensearch/index-template-pagexray.json @@ -0,0 +1,92 @@ +{ + "index_patterns": ["sitespeed-pagexray-*"], + "template": { + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0, + "plugins.index_state_management.policy_id": "sitespeed-default-retention" + }, + "mappings": { + "dynamic": "strict", + "properties": { + "@timestamp": { "type": "date" }, + "url": { "type": "keyword" }, + "group": { "type": "keyword" }, + "alias": { "type": "keyword" }, + "browser": { "type": "keyword" }, + "connectivity":{ "type": "keyword" }, + "slug": { "type": "keyword" }, + "type": { "type": "keyword" }, + "resultUrl": { "type": "keyword" }, + "deviceType": { "type": "keyword" }, + "iterations": { "type": "integer" }, + "name": { "type": "keyword" }, + "totalDomains":{ "type": "integer" }, + "transferSize":{ "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "contentSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "requests": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "cookies": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } }, + "firstParty": { + "properties": { + "transferSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "contentSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "requests": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } } + } + }, + "thirdParty": { + "properties": { + "transferSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "contentSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "requests": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } } + } + }, + "contentTypes": { + "properties": { + "javascript": { + "properties": { + "transferSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "contentSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "requests": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } } + } + }, + "css": { + "properties": { + "transferSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "contentSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "requests": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } } + } + }, + "image": { + "properties": { + "transferSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "contentSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "requests": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } } + } + }, + "html": { + "properties": { + "transferSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "contentSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "requests": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } } + } + }, + "font": { + "properties": { + "transferSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "contentSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "requests": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } } + } + }, + "other": { + "properties": { + "transferSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "contentSize": { "properties": { "median": { "type": "long" }, "p90": { "type": "long" }, "rsd": { "type": "float" } } }, + "requests": { "properties": { "median": { "type": "float" }, "p90": { "type": "float" }, "rsd": { "type": "float" } } } + } + } + } + } + } + } + } +} diff --git a/docker/opensearch/index-template-run.json b/docker/opensearch/index-template-run.json new file mode 100644 index 0000000000..37391f2553 --- /dev/null +++ b/docker/opensearch/index-template-run.json @@ -0,0 +1,69 @@ +{ + "index_patterns": ["sitespeed-run-*"], + "template": { + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0, + "plugins.index_state_management.policy_id": "sitespeed-run-retention" + }, + "mappings": { + "dynamic": "strict", + "properties": { + "@timestamp": { "type": "date" }, + "url": { "type": "keyword" }, + "group": { "type": "keyword" }, + "alias": { "type": "keyword" }, + "browser": { "type": "keyword" }, + "connectivity":{ "type": "keyword" }, + "slug": { "type": "keyword" }, + "type": { "type": "keyword" }, + "resultUrl": { "type": "keyword" }, + "deviceType": { "type": "keyword" }, + "iterations": { "type": "integer" }, + "iteration": { "type": "integer" }, + "name": { "type": "keyword" }, + "timings": { + "properties": { + "firstPaint": { "type": "float" }, + "fcp": { "type": "float" }, + "lcp": { "type": "float" }, + "ttfb": { "type": "float" }, + "fullyLoaded": { "type": "float" }, + "loadEventEnd":{ "type": "float" } + } + }, + "visualMetrics": { + "properties": { + "speedIndex": { "type": "float" }, + "perceptualSpeedIndex": { "type": "float" }, + "firstVisualChange": { "type": "float" }, + "lastVisualChange": { "type": "float" }, + "visualComplete85": { "type": "float" }, + "visualComplete95": { "type": "float" }, + "visualComplete99": { "type": "float" } + } + }, + "googleWebVitals": { + "properties": { + "lcp": { "type": "float" }, + "cls": { "type": "float" }, + "inp": { "type": "float" }, + "fid": { "type": "float" } + } + }, + "cpu": { + "properties": { + "longTasksTotalDuration": { "type": "float" }, + "longTasksCount": { "type": "float" } + } + }, + "pageInfo": { + "properties": { + "cumulativeLayoutShift": { "type": "float" }, + "domElements": { "type": "integer" } + } + } + } + } + } +} diff --git a/docker/opensearch/ism-policy-default.json b/docker/opensearch/ism-policy-default.json new file mode 100644 index 0000000000..8931962267 --- /dev/null +++ b/docker/opensearch/ism-policy-default.json @@ -0,0 +1,25 @@ +{ + "policy": { + "description": "Delete sitespeed summary indices after 90 days. Page summaries, pagexray and compare data are kept longer for trend analysis.", + "default_state": "hot", + "states": [ + { + "name": "hot", + "actions": [], + "transitions": [ + { + "state_name": "delete", + "conditions": { + "min_index_age": "90d" + } + } + ] + }, + { + "name": "delete", + "actions": [{ "delete": {} }], + "transitions": [] + } + ] + } +} diff --git a/docker/opensearch/ism-policy-run.json b/docker/opensearch/ism-policy-run.json new file mode 100644 index 0000000000..e3d07cbfad --- /dev/null +++ b/docker/opensearch/ism-policy-run.json @@ -0,0 +1,25 @@ +{ + "policy": { + "description": "Delete sitespeed run indices after 7 days. Run-level data is only useful for short-term debugging.", + "default_state": "hot", + "states": [ + { + "name": "hot", + "actions": [], + "transitions": [ + { + "state_name": "delete", + "conditions": { + "min_index_age": "7d" + } + } + ] + }, + { + "name": "delete", + "actions": [{ "delete": {} }], + "transitions": [] + } + ] + } +} diff --git a/docker/opensearch/setup.sh b/docker/opensearch/setup.sh new file mode 100755 index 0000000000..fd185cd7a0 --- /dev/null +++ b/docker/opensearch/setup.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Apply sitespeed.io index templates to OpenSearch. +# ISM retention policies are created automatically by the sitespeed.io +# OpenSearch plugin on first run (configurable via --opensearch.retentionDays +# and --opensearch.runRetentionDays). +# +# Usage: +# ./setup.sh # connects to http://localhost:9200 +# ./setup.sh http://my-host:9200 +# ./setup.sh http://my-host:9200 user:password + +set -e + +HOST="${1:-http://localhost:9200}" +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +CURL_OPTS=(-sf -H 'Content-Type: application/json') +if [ -n "$2" ]; then + CURL_OPTS+=(-u "$2") +fi + +echo "Applying sitespeed.io index templates to OpenSearch at $HOST" + +for template in pagesummary run pagexray compare coach; do + echo " -> sitespeed-${template}" + curl "${CURL_OPTS[@]}" -X PUT "$HOST/_index_template/sitespeed-${template}" \ + -d @"$DIR/index-template-${template}.json" + echo +done + +echo "Done. Start sitespeed.io with --opensearch.host to activate the plugin." +echo "ISM retention policies will be created on first run." diff --git a/lib/cli/cli.js b/lib/cli/cli.js index 4eea280fd3..c908346d12 100644 --- a/lib/cli/cli.js +++ b/lib/cli/cli.js @@ -20,6 +20,7 @@ import { addOptions as addSafariOptions } from './options/safari.js'; import { addOptions as addAndroidOptions } from './options/android.js'; import { addOptions as addScreenshotOptions } from './options/screenshot.js'; import { addOptions as addGraphiteOptions } from './options/graphite.js'; +import { addOptions as addOpensearchOptions } from './options/opensearch.js'; import { addOptions as addGrafanaOptions } from './options/grafana.js'; import { addOptions as addHtmlOptions } from './options/html.js'; import { addOptions as addSlackOptions } from './options/slack.js'; @@ -64,6 +65,7 @@ export async function parseCommandLine() { addAndroidOptions(yargsInstance); addScreenshotOptions(yargsInstance); addGraphiteOptions(yargsInstance); + addOpensearchOptions(yargsInstance); addGrafanaOptions(yargsInstance); addHtmlOptions(yargsInstance); addSlackOptions(yargsInstance); diff --git a/lib/cli/options/opensearch.js b/lib/cli/options/opensearch.js new file mode 100644 index 0000000000..0ebb84b5bc --- /dev/null +++ b/lib/cli/options/opensearch.js @@ -0,0 +1,53 @@ +export function addOptions(yargs) { + yargs + .option('opensearch.host', { + describe: 'The OpenSearch host to store captured metrics.', + group: 'OpenSearch' + }) + .option('opensearch.port', { + default: 9200, + describe: 'The OpenSearch port.', + group: 'OpenSearch' + }) + .option('opensearch.index', { + default: 'sitespeed', + describe: + 'The index name prefix. Indices are created as {prefix}-pagesummary-YYYY.MM.DD, {prefix}-run-YYYY.MM.DD and {prefix}-compare-YYYY.MM.DD.', + group: 'OpenSearch' + }) + .option('opensearch.secure', { + default: false, + type: 'boolean', + describe: 'Use HTTPS when connecting to OpenSearch.', + group: 'OpenSearch' + }) + .option('opensearch.username', { + describe: 'OpenSearch username for Basic Auth.', + group: 'OpenSearch' + }) + .option('opensearch.password', { + describe: 'OpenSearch password for Basic Auth.', + group: 'OpenSearch' + }) + .option('opensearch.includeRuns', { + default: false, + type: 'boolean', + describe: + 'Send per-iteration run data to OpenSearch in addition to page summaries.', + group: 'OpenSearch' + }) + .option('opensearch.runRetentionDays', { + default: 7, + type: 'number', + describe: + 'Number of days to retain per-iteration run data in OpenSearch.', + group: 'OpenSearch' + }) + .option('opensearch.retentionDays', { + default: 90, + type: 'number', + describe: + 'Number of days to retain page summary, pagexray, coach and compare data in OpenSearch.', + group: 'OpenSearch' + }); +} diff --git a/lib/plugins/opensearch/data-generator.js b/lib/plugins/opensearch/data-generator.js new file mode 100644 index 0000000000..5fa3e4b8e2 --- /dev/null +++ b/lib/plugins/opensearch/data-generator.js @@ -0,0 +1,355 @@ +import get from 'lodash.get'; +import { getConnectivity } from '../../support/tsdbUtil.js'; + +function getDeviceType(options) { + if (options.safari?.ios || options.browsertime?.safari?.ios) return 'ios'; + if (options.android?.enabled || options.browsertime?.android?.enabled) + return 'android'; + if (options.mobile) return 'mobile'; + return 'desktop'; +} + +/** + * Extract a statistics object (median/p90/p99) from a data path. + * Returns undefined if the path doesn't exist or has no numeric stats. + */ +function stat(data, path) { + const s = get(data, path); + if (!s || typeof s !== 'object') return; + const result = {}; + if (typeof s.median === 'number') result.median = s.median; + if (typeof s.p90 === 'number') result.p90 = s.p90; + if (typeof s.rsd === 'number') result.rsd = s.rsd; + return Object.keys(result).length > 0 ? result : undefined; +} + +/** + * Strip undefined/null/empty-object entries so we don't send empty + * nested objects to OpenSearch (which would still create mapping entries). + */ +function compact(obj) { + return Object.fromEntries( + Object.entries(obj).filter(([, v]) => { + if (v === undefined || v === null) return false; + if ( + typeof v === 'object' && + !Array.isArray(v) && + Object.keys(v).length === 0 + ) + return false; + return true; + }) + ); +} + +function baseDoc(message, options, alias, resultUrls) { + const doc = { + '@timestamp': new Date(message.runTime ?? Date.now()).toISOString(), + url: message.url, + group: message.group, + alias: alias?.[message.url], + browser: options.browser, + connectivity: getConnectivity(options), + slug: options.slug, + deviceType: getDeviceType(options), + iterations: options.browsertime?.iterations, + name: options.name + }; + if (resultUrls?.hasBaseUrl()) { + doc.resultUrl = + resultUrls.absoluteSummaryPagePath(message.url, alias?.[message.url]) + + 'index.html'; + } + return doc; +} + +export class OpenSearchDataGenerator { + constructor(options) { + this.options = options; + } + + documentFromMessage(message, alias, resultUrls) { + switch (message.type) { + case 'browsertime.pageSummary': { + return this.fromPageSummary(message, alias, resultUrls); + } + case 'browsertime.run': { + return this.fromRun(message, alias, resultUrls); + } + case 'compare.pageSummary': { + return this.fromCompare(message, alias, resultUrls); + } + case 'pagexray.pageSummary': { + return this.fromPageXray(message, alias, resultUrls); + } + case 'coach.pageSummary': { + return this.fromCoach(message, alias, resultUrls); + } + default: { + return; + } + } + } + + fromPageSummary(message, alias, resultUrls) { + const d = message.data; + + return compact({ + ...baseDoc(message, this.options, alias, resultUrls), + type: 'pageSummary', + timings: compact({ + firstPaint: stat(d, 'statistics.timings.firstPaint'), + fcp: stat(d, 'statistics.timings.timeToContentfulPaint'), + lcp: stat(d, 'statistics.timings.largestContentfulPaint.renderTime'), + ttfb: stat(d, 'statistics.timings.ttfb'), + fullyLoaded: stat(d, 'statistics.timings.fullyLoaded'), + loadEventEnd: stat(d, 'statistics.timings.loadEventEnd'), + timeToInteractive: stat(d, 'statistics.timings.timeToFirstInteractive') + }), + visualMetrics: compact({ + speedIndex: stat(d, 'statistics.visualMetrics.SpeedIndex'), + perceptualSpeedIndex: stat( + d, + 'statistics.visualMetrics.PerceptualSpeedIndex' + ), + firstVisualChange: stat( + d, + 'statistics.visualMetrics.FirstVisualChange' + ), + lastVisualChange: stat(d, 'statistics.visualMetrics.LastVisualChange'), + visualComplete85: stat(d, 'statistics.visualMetrics.VisualComplete85'), + visualComplete95: stat(d, 'statistics.visualMetrics.VisualComplete95'), + visualComplete99: stat(d, 'statistics.visualMetrics.VisualComplete99') + }), + googleWebVitals: compact({ + lcp: stat(d, 'statistics.googleWebVitals.largestContentfulPaint'), + cls: stat(d, 'statistics.googleWebVitals.cumulativeLayoutShift'), + inp: stat(d, 'statistics.googleWebVitals.interactionToNextPaint'), + fid: stat(d, 'statistics.googleWebVitals.firstInputDelay') + }), + cpu: compact({ + longTasksTotalDuration: stat( + d, + 'statistics.cpu.longTasks.totalDuration' + ), + longTasksCount: stat(d, 'statistics.cpu.longTasks.count') + }), + pageInfo: compact({ + cumulativeLayoutShift: stat( + d, + 'statistics.pageinfo.cumulativeLayoutShift' + ), + domElements: stat(d, 'statistics.pageinfo.domElements') + }) + }); + } + + fromCompare(message, alias, resultUrls) { + const d = message.data; + + // Extract a single metric's compare result into a flat, safe shape. + // We store isSignificant as a boolean (the raw value is cliffsDelta or 0). + function cm(group, metricName) { + const m = get(d, `metrics.${group}.${metricName}`); + if (!m) return; + return compact({ + isSignificant: m.isSignificant !== 0, + cliffsDelta: + typeof m.cliffsDelta === 'number' ? m.cliffsDelta : undefined, + pValue: + typeof m.statisticalTestU === 'number' + ? m.statisticalTestU + : undefined, + currentMedian: + typeof m.current?.median === 'number' ? m.current.median : undefined, + baselineMedian: + typeof m.baseline?.median === 'number' ? m.baseline.median : undefined + }); + } + + // Collect the dotted paths of any metric that is statistically significant. + const TRACKED = { + timings: ['ttfb', 'firstContentfulPaint', 'fullyLoaded', 'loadEventEnd'], + visualMetrics: [ + 'SpeedIndex', + 'PerceptualSpeedIndex', + 'FirstVisualChange', + 'LastVisualChange', + 'ContentfulSpeedIndex' + ], + googleWebVitals: [ + 'largestContentfulPaint', + 'cumulativeLayoutShift', + 'interactionToNextPaint', + 'firstInputDelay' + ], + cpu: [ + 'totalDuration', + 'tasks', + 'lastLongTask', + 'beforeFirstContentfulPaint', + 'beforeLargestContentfulPaint' + ], + renderBlocking: [ + 'beforeFCPms', + 'beforeLCPms', + 'beforeFCPelements', + 'beforeLCPelements' + ] + }; + + const significantMetrics = []; + for (const [group, names] of Object.entries(TRACKED)) { + for (const name of names) { + const m = get(d, `metrics.${group}.${name}`); + if (m && m.isSignificant !== 0) { + significantMetrics.push(`${group}.${name}`); + } + } + } + + return compact({ + ...baseDoc(message, this.options, alias, resultUrls), + type: 'compare', + iterations: get(d, 'meta.iterations'), + baselineTimestamp: get(d, 'meta.baseline.timestamp'), + anySignificant: significantMetrics.length > 0, + significantMetrics: + significantMetrics.length > 0 ? significantMetrics : undefined, + timings: compact({ + ttfb: cm('timings', 'ttfb'), + fcp: cm('timings', 'firstContentfulPaint'), + fullyLoaded: cm('timings', 'fullyLoaded'), + loadEventEnd: cm('timings', 'loadEventEnd') + }), + visualMetrics: compact({ + speedIndex: cm('visualMetrics', 'SpeedIndex'), + perceptualSpeedIndex: cm('visualMetrics', 'PerceptualSpeedIndex'), + firstVisualChange: cm('visualMetrics', 'FirstVisualChange'), + lastVisualChange: cm('visualMetrics', 'LastVisualChange'), + contentfulSpeedIndex: cm('visualMetrics', 'ContentfulSpeedIndex') + }), + googleWebVitals: compact({ + lcp: cm('googleWebVitals', 'largestContentfulPaint'), + cls: cm('googleWebVitals', 'cumulativeLayoutShift'), + inp: cm('googleWebVitals', 'interactionToNextPaint'), + fid: cm('googleWebVitals', 'firstInputDelay') + }), + cpu: compact({ + totalDuration: cm('cpu', 'totalDuration'), + tasks: cm('cpu', 'tasks'), + lastLongTask: cm('cpu', 'lastLongTask'), + beforeFCP: cm('cpu', 'beforeFirstContentfulPaint'), + beforeLCP: cm('cpu', 'beforeLargestContentfulPaint') + }), + renderBlocking: compact({ + beforeFCPms: cm('renderBlocking', 'beforeFCPms'), + beforeLCPms: cm('renderBlocking', 'beforeLCPms'), + beforeFCPelements: cm('renderBlocking', 'beforeFCPelements'), + beforeLCPelements: cm('renderBlocking', 'beforeLCPelements') + }) + }); + } + + fromCoach(message, alias, resultUrls) { + const d = message.data; + + return compact({ + ...baseDoc(message, this.options, alias, resultUrls), + type: 'coach', + score: stat(d, 'advice.score'), + performance: stat(d, 'advice.performance.score'), + privacy: stat(d, 'advice.privacy.score'), + bestpractice: stat(d, 'advice.bestpractice.score'), + info: compact({ + documentHeight: stat(d, 'advice.info.documentHeight'), + domElements: stat(d, 'advice.info.domElements'), + domDepth: stat(d, 'advice.info.domDepth'), + iframes: stat(d, 'advice.info.iframes'), + scripts: stat(d, 'advice.info.scripts'), + localStorageSize: stat(d, 'advice.info.localStorageSize') + }) + }); + } + + fromPageXray(message, alias, resultUrls) { + const d = message.data; + + function ct(type) { + return compact({ + transferSize: stat(d, `contentTypes.${type}.transferSize`), + contentSize: stat(d, `contentTypes.${type}.contentSize`), + requests: stat(d, `contentTypes.${type}.requests`) + }); + } + + return compact({ + ...baseDoc(message, this.options, alias, resultUrls), + type: 'pagexray', + transferSize: stat(d, 'transferSize'), + contentSize: stat(d, 'contentSize'), + requests: stat(d, 'requests'), + totalDomains: get(d, 'totalDomains'), + cookies: stat(d, 'cookies'), + firstParty: compact({ + transferSize: stat(d, 'firstParty.transferSize'), + contentSize: stat(d, 'firstParty.contentSize'), + requests: stat(d, 'firstParty.requests') + }), + thirdParty: compact({ + transferSize: stat(d, 'thirdParty.transferSize'), + contentSize: stat(d, 'thirdParty.contentSize'), + requests: stat(d, 'thirdParty.requests') + }), + contentTypes: compact({ + javascript: ct('javascript'), + css: ct('css'), + image: ct('image'), + html: ct('html'), + font: ct('font'), + other: ct('other') + }) + }); + } + + fromRun(message, alias, resultUrls) { + const d = message.data; + + return compact({ + ...baseDoc(message, this.options, alias, resultUrls), + type: 'run', + iteration: message.iteration, + timings: compact({ + firstPaint: get(d, 'timings.firstPaint'), + fcp: get(d, 'timings.timeToContentfulPaint'), + lcp: get(d, 'timings.largestContentfulPaint.renderTime'), + ttfb: get(d, 'timings.ttfb'), + fullyLoaded: get(d, 'timings.fullyLoaded'), + loadEventEnd: get(d, 'timings.loadEventEnd') + }), + visualMetrics: compact({ + speedIndex: get(d, 'visualMetrics.SpeedIndex'), + perceptualSpeedIndex: get(d, 'visualMetrics.PerceptualSpeedIndex'), + firstVisualChange: get(d, 'visualMetrics.FirstVisualChange'), + lastVisualChange: get(d, 'visualMetrics.LastVisualChange'), + visualComplete85: get(d, 'visualMetrics.VisualComplete85'), + visualComplete95: get(d, 'visualMetrics.VisualComplete95'), + visualComplete99: get(d, 'visualMetrics.VisualComplete99') + }), + googleWebVitals: compact({ + lcp: get(d, 'googleWebVitals.largestContentfulPaint'), + cls: get(d, 'googleWebVitals.cumulativeLayoutShift'), + inp: get(d, 'googleWebVitals.interactionToNextPaint'), + fid: get(d, 'googleWebVitals.firstInputDelay') + }), + cpu: compact({ + longTasksTotalDuration: get(d, 'cpu.longTasks.totalDuration'), + longTasksCount: get(d, 'cpu.longTasks.count') + }), + pageInfo: compact({ + cumulativeLayoutShift: get(d, 'pageinfo.cumulativeLayoutShift'), + domElements: get(d, 'pageinfo.domElements') + }) + }); + } +} diff --git a/lib/plugins/opensearch/index.js b/lib/plugins/opensearch/index.js new file mode 100644 index 0000000000..eeb5f4b65b --- /dev/null +++ b/lib/plugins/opensearch/index.js @@ -0,0 +1,97 @@ +import { getLogger } from '@sitespeed.io/log'; +import { SitespeedioPlugin } from '@sitespeed.io/plugin'; + +import { throwIfMissing } from '../../support/util.js'; +import { OpenSearchSender } from './sender.js'; +import { OpenSearchDataGenerator } from './data-generator.js'; + +const log = getLogger('sitespeedio.plugin.opensearch'); + +export default class OpenSearchPlugin extends SitespeedioPlugin { + constructor(options, context, queue) { + super({ name: 'opensearch', options, context, queue }); + } + + async open(context, options) { + throwIfMissing(options.opensearch, ['host'], 'opensearch'); + + const o = options.opensearch; + this.options = options; + this.dataGenerator = new OpenSearchDataGenerator(options); + this.sender = new OpenSearchSender(o.host, o.port, o.index, { + secure: o.secure ?? false, + username: o.username, + password: o.password + }); + this.alias = {}; + this.resultUrls = context.resultUrls; + this.includeRuns = o.includeRuns ?? false; + + const runDays = o.runRetentionDays ?? 7; + const defaultDays = o.retentionDays ?? 90; + + log.info( + 'Setting up OpenSearch %s:%s index prefix: %s, run retention: %dd, summary retention: %dd', + o.host, + o.port ?? 9200, + o.index ?? 'sitespeed', + runDays, + defaultDays + ); + + try { + await this.sender.putISMPolicy('sitespeed-run-retention', `${runDays}d`); + await this.sender.putISMPolicy( + 'sitespeed-default-retention', + `${defaultDays}d` + ); + log.debug('OpenSearch ISM retention policies created/updated'); + } catch (error) { + log.warning( + 'Could not create OpenSearch ISM retention policies: %s', + error.message + ); + } + } + + processMessage(message) { + if (message.type === 'browsertime.alias') { + this.alias[message.url] = message.data; + return; + } + + if (message.type === 'browsertime.run' && !this.includeRuns) { + return; + } + + if (message.group === 'total') { + return; + } + + const doc = this.dataGenerator.documentFromMessage( + message, + this.alias, + this.resultUrls + ); + if (!doc) return; + + const INDEX_TYPES = { + 'browsertime.pageSummary': 'pagesummary', + 'browsertime.run': 'run', + 'pagexray.pageSummary': 'pagexray', + 'compare.pageSummary': 'compare', + 'coach.pageSummary': 'coach' + }; + const indexType = INDEX_TYPES[message.type] ?? 'pagesummary'; + + return this.sender + .bulk([doc], this.sender.indexName(indexType)) + .catch(error => { + log.error( + 'Failed to send %s to OpenSearch: %s', + message.type, + error.message + ); + }); + } +} diff --git a/lib/plugins/opensearch/sender.js b/lib/plugins/opensearch/sender.js new file mode 100644 index 0000000000..0e64c99b00 --- /dev/null +++ b/lib/plugins/opensearch/sender.js @@ -0,0 +1,99 @@ +import { getLogger } from '@sitespeed.io/log'; + +const log = getLogger('sitespeedio.plugin.opensearch'); + +export class OpenSearchSender { + constructor(host, port, index, options = {}) { + this.baseUrl = `${options.secure ? 'https' : 'http'}://${host}:${port || 9200}`; + this.indexPrefix = index || 'sitespeed'; + if (options.username && options.password) { + const encoded = Buffer.from( + `${options.username}:${options.password}` + ).toString('base64'); + this.authHeader = `Basic ${encoded}`; + } + } + + indexName(type) { + const date = new Date().toISOString().slice(0, 10).replaceAll('-', '.'); + return type + ? `${this.indexPrefix}-${type}-${date}` + : `${this.indexPrefix}-${date}`; + } + + async putISMPolicy(policyId, minIndexAge) { + const url = `${this.baseUrl}/_plugins/_ism/policies/${policyId}`; + const body = JSON.stringify({ + policy: { + description: `Delete sitespeed ${policyId} indices after ${minIndexAge}`, + default_state: 'hot', + states: [ + { + name: 'hot', + actions: [], + transitions: [ + { + state_name: 'delete', + conditions: { min_index_age: minIndexAge } + } + ] + }, + { + name: 'delete', + actions: [{ delete: {} }], + transitions: [] + } + ] + } + }); + + const headers = { 'Content-Type': 'application/json' }; + if (this.authHeader) { + headers['Authorization'] = this.authHeader; + } + + const response = await fetch(url, { method: 'PUT', headers, body }); + if (!response.ok && response.status !== 409) { + const text = await response.text(); + throw new Error( + `Failed to create ISM policy ${policyId} (${response.status}): ${text}` + ); + } + } + + async bulk(documents, index) { + if (documents.length === 0) return; + + const index_ = index ?? this.indexName(); + const body = + documents + .flatMap(doc => [ + JSON.stringify({ index: { _index: index_ } }), + JSON.stringify(doc) + ]) + .join('\n') + '\n'; + + const url = `${this.baseUrl}/_bulk`; + log.debug('Sending %d document(s) to OpenSearch %s', documents.length, url); + + const headers = { 'Content-Type': 'application/x-ndjson' }; + if (this.authHeader) { + headers['Authorization'] = this.authHeader; + } + + const response = await fetch(url, { method: 'POST', headers, body }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`OpenSearch bulk failed (${response.status}): ${text}`); + } + + const result = await response.json(); + if (result.errors) { + const errors = result.items + .filter(item => item.index?.error) + .map(item => item.index.error); + log.error('OpenSearch bulk had %d error(s): %j', errors.length, errors); + } + } +} diff --git a/test/docker/docker-compose.opensearch.yml b/test/docker/docker-compose.opensearch.yml new file mode 100644 index 0000000000..04621dee99 --- /dev/null +++ b/test/docker/docker-compose.opensearch.yml @@ -0,0 +1,14 @@ +services: + opensearch: + image: opensearchproject/opensearch:2 + environment: + - discovery.type=single-node + - DISABLE_SECURITY_PLUGIN=true + - OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m + ports: + - "9200:9200" + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:9200/_cluster/health"] + interval: 5s + timeout: 5s + retries: 20