Repository: elastic/synthetics
Pull request #744 (2023-04-24 10:47:15)

Migrate release to gh actions workflow
## Details

- Migrate release pipeline to GH actions workflow
- Migrate dist-tag pipeline to GH actions workflow
- Update release section CONTRIBUTING.md
- Update status badge in README.md

reakaleek: @v1v

in the Jenkinsfile there were some functions to setup docker credentials and docker login steps.
However, there were no docker related scripts.

Can you confirm that those were leftovers and it's safe to omit?

Patch:
diff --git a/.ci/Jenkinsfile b/.ci/Jenkinsfile
deleted file mode 100644
index 4ada75d..0000000
--- a/.ci/Jenkinsfile
+++ /dev/null
@@ -1,208 +0,0 @@
-#!/usr/bin/env groovy
-
-@Library('apm@current') _
-
-pipeline {
-  agent { label 'ubuntu-20 && immutable' }
-  environment {
-    REPO = "synthetics"
-    BASE_DIR = "src/github.com/elastic/${env.REPO}"
-    PIPELINE_LOG_LEVEL = 'INFO'
-    DOCKERELASTIC_SECRET = 'secret/observability-team/ci/docker-registry/prod'
-    DOCKER_REGISTRY = 'docker.elastic.co'
-    NODE_DOCKER_IMAGE = "${env.DOCKER_REGISTRY}/observability-ci/node-cypress:latest"
-    SECCOMP_FILE = "${env.WORKSPACE}/${env.BASE_DIR}/.ci/seccomp_profile.json"
-    NPMRC_SECRET = 'secret/jenkins-ci/npmjs/elasticmachine'
-    TOTP_SECRET = 'totp/code/npmjs-elasticmachine'
-    SLACK_CHANNEL = '#synthetics-user_experience-uptime'
-  }
-  options {
-    timeout(time: 12, unit: 'HOURS')  // to support releases then we will add a timeout in each stage
-    buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20', daysToKeepStr: '30'))
-    timestamps()
-    ansiColor('xterm')
-    disableResume()
-    durabilityHint('PERFORMANCE_OPTIMIZED')
-    rateLimitBuilds(throttle: [count: 60, durationName: 'hour', userBoost: true])
-    quietPeriod(10)
-  }
-  triggers {
-    issueCommentTrigger("${obltGitHubComments()}")
-    upstream("apm-agent-rum/heartbeat-elastic-synthetics/${ env.JOB_BASE_NAME.startsWith('PR-') ? 'none' : 'heartbeat-synthetics' }, apm-agent-rum/heartbeat-elastic-synthetics/${ env.JOB_BASE_NAME.startsWith('PR-') ? 'none' : 'heartbeat-synthetics' }-7.x")
-  }
-  parameters {
-    booleanParam(name: 'run_all_stages', defaultValue: false, description: 'Force to run all stages.')
-    booleanParam(name: 'release', defaultValue: false, description: 'Release. If so, all the other parameters will be ignored when releasing from main.')
-  }
-  stages {
-    /**
-     Checkout the code and stash it, to use it on other stages.
-     */
-    stage('Checkout') {
-      options {
-        skipDefaultCheckout()
-        timeout(5)
-      }
-      steps {
-        deleteDir()
-        gitCheckout(basedir: "${BASE_DIR}")
-        retryWithSleep(retries: 3, seconds: 5, backoff: true) {
-          dockerLogin(secret: "${DOCKERELASTIC_SECRET}",
-                      registry: "${DOCKER_REGISTRY}")
-          withNodeJSEnv(){
-            dir("${BASE_DIR}"){
-              sh(label: 'Download dependencies',script: 'npm install')
-            }
-          }
-        }
-        stash allowEmpty: true, name: 'source', useDefaultExcludes: false, excludes: ".nvm/**,.npm/_cacache/**,.nvm/.git/**"
-      }
-    }
-    stage('Release') {
-      options {
-        skipDefaultCheckout()
-      }
-      when {
-        beforeAgent true
-        allOf {
-          branch 'main'
-          expression { return params.release }
-        }
-      }
-      agent { label 'ubuntu-20 && immutable' }
-      stages {
-        stage('Approve to Release') {
-          options { skipDefaultCheckout() }
-          steps {
-            notifyStatus(slackStatus: 'warning', subject: "[${env.REPO}] Release ready to be pushed",
-                         body: "Please go to (<${env.BUILD_URL}input|here>) to approve or reject within 12 hours.")
-            setEnvVar('RELEASE', prompt(message: 'Should we release a new version?'))
-          }
-        }
-        stage('Release CI') {
-          when {
-            beforeAgent true
-            expression { return env.RELEASE == 'true' }
-          }
-          options { skipDefaultCheckout() }
-          environment {
-            NPM_PACKAGE = '@elastic/synthetics'
-          }
-          steps {
-            cleanup()
-            withNodeJSEnv() {
-              dir("${BASE_DIR}") {
-                withNpmrc(secret: "${env.NPMRC_SECRET}", path: "${env.WORKSPACE}/${env.BASE_DIR}") {
-                  withGitRelease(credentialsId: '2a9602aa-ab9f-4e52-baf3-b71ca88469c7-UserAndToken') {
-                    sh(script: 'npm run clean', label: 'clean release')
-                    sh(script: 'npm ci', label: 'prepare the ci environment')
-                    setEnvVar('VERSION', sh(script: 'npm version prerelease --preid=beta', label: 'bump the beta version', returnStdout: true).trim())
-                    gitPush()
-                    gitPush(args: '--tags')
-                    log(level: 'INFO', text: "Version to be published '${env.VERSION}'")
-                    withTotpVault(secret: "${env.TOTP_SECRET}", code_var_name: 'TOTP_CODE'){
-                      sh(script: "npm publish --otp=${env.TOTP_CODE} --tag beta", label: 'publish to NPM')
-                    }
-                    withTotpVault(secret: "${env.TOTP_SECRET}", code_var_name: 'TOTP_CODE'){
-                      sh(script: "npm dist-tag --otp=${env.TOTP_CODE} add ${env.NPM_PACKAGE}@${env.VERSION} latest", label: 'retag latest')
-                    }
-                  }
-                }
-              }
-            }
-          }
-          post {
-            success {
-              notifyStatus(slackStatus: 'good', subject: "[${env.REPO}] Release '${env.VERSION}' published", body: "Great news, the release has been done successfully. (<${env.RUN_DISPLAY_URL}|Open>). \n Release URL: (<https://preview.npmjs.com/package/${env.NPM_PACKAGE}/v/${env.VERSION}|${env.VERSION}>)")
-            }
-            always {
-              script {
-                currentBuild.description = "${currentBuild.description?.trim() ? currentBuild.description : ''} released"
-              }
-            }
-            failure {
-              notifyStatus(slackStatus: 'danger', subject: "[${env.REPO}] Release '${env.VERSION?.trim()}' failed", body: "(<${env.RUN_DISPLAY_URL}|Open>)")
-            }
-          }
-        }
-        stage('Approve to Stack Release') {
-          when {
-            beforeAgent true
-            expression { return env.RELEASE == 'true' }
-          }
-          options { skipDefaultCheckout() }
-          steps {
-            notifyStatus(slackStatus: 'warning', subject: "[${env.REPO}] Stack Release ready to be pushed",
-                         body: "Please go to (<${env.BUILD_URL}input|here>) to approve or reject within 12 hours.")
-            setEnvVar('STACK_RELEASE', prompt(message: 'Should we tag the current version as a stack release?'))
-          }
-        }
-        stage('Stack Release CI') {
-          when {
-            beforeAgent true
-            expression { return env.RELEASE == 'true' && env.STACK_RELEASE == 'true' }
-          }
-          options { skipDefaultCheckout() }
-          environment {
-            NPM_PACKAGE = '@elastic/synthetics'
-          }
-          steps {
-            cleanup()
-            withNodeJSEnv() {
-              dir("${BASE_DIR}") {
-                withNpmrc(secret: "${env.NPMRC_SECRET}", path: "${env.WORKSPACE}/${env.BASE_DIR}") {
-                  withTotpVault(secret: "${env.TOTP_SECRET}", code_var_name: 'TOTP_CODE') {
-                    setEnvVar('VERSION', sh(script: 'npm show . version', label: 'tag the version as stack release', returnStdout: true).trim())
-                    sh(script: "npm dist-tag --otp=${env.TOTP_CODE} add ${env.NPM_PACKAGE}@${env.VERSION} stack_release", label: 'tag stack_release')
-                  }
-                }
-              }
-            }
-          }
-          post {
-            success {
-              notifyStatus(slackStatus: 'good', subject: "[${env.REPO}] Stack Release '${env.VERSION}' published", body: "Great news, the stack release has been done successfully. (<${env.RUN_DISPLAY_URL}|Open>). \n Release URL: (<https://preview.npmjs.com/package/${env.NPM_PACKAGE}/v/${env.VERSION}|${env.VERSION}>)")
-            }
-            always {
-              script {
-                currentBuild.description = "${currentBuild.description?.trim() ? currentBuild.description : ''} released"
-              }
-            }
-            failure {
-              notifyStatus(slackStatus: 'danger', subject: "[${env.REPO}] Stack Release '${env.VERSION?.trim()}' failed", body: "(<${env.RUN_DISPLAY_URL}|Open>)")
-            }
-          }
-        }
-      }
-    }
-  }
-}
-
-def cleanup(){
-  dir("${BASE_DIR}") {
-    deleteDir()
-  }
-  unstash 'source'
-}
-
-def withNodeInDockerEnv(Map args=[:], Closure body) {
-  docker.image("${NODE_DOCKER_IMAGE}").inside(" --security-opt seccomp=${SECCOMP_FILE}"){
-    withEnv(["HOME=${WORKSPACE}"]) {
-      body()
-    }
-  }
-}
-
-/**
-  This is the wrapper to send notifications for the release process through
-  slack and email, since it requires some formatting to support the same
-  message in both systems.
- */
-def notifyStatus(def args = [:]) {
-  releaseNotification(slackChannel: "${env.SLACK_CHANNEL}",
-                      slackColor: args.slackStatus,
-                      slackCredentialsId: 'jenkins-slack-integration-token',
-                      to: 'synthrum@elastic.co',
-                      subject: args.subject,
-                      body: args.body)
-}
diff --git a/.ci/dist-tag.groovy b/.ci/dist-tag.groovy
deleted file mode 100644
index 63eacf5..0000000
--- a/.ci/dist-tag.groovy
+++ /dev/null
@@ -1,78 +0,0 @@
-#!/usr/bin/env groovy
-
-@Library('apm@current') _
-
-pipeline {
-  agent { label 'linux && immutable' }
-  environment {
-    REPO = "synthetics"
-    BASE_DIR = "src/github.com/elastic/${env.REPO}"
-    NPMRC_SECRET = 'secret/jenkins-ci/npmjs/elasticmachine'
-    TOTP_SECRET = 'totp/code/npmjs-elasticmachine'
-    NPM_PACKAGE = '@elastic/synthetics'
-    SLACK_CHANNEL = '#synthetics-user_experience-uptime'
-    VERSION = "${params.VERSION}"
-    DIST_TAG = "${params.DIST_TAG}"
-  }
-  options {
-    timeout(time: 1, unit: 'HOURS')  // to support releases then we will add a timeout in each stage
-    buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20', daysToKeepStr: '30'))
-    timestamps()
-    ansiColor('xterm')
-    disableResume()
-    durabilityHint('PERFORMANCE_OPTIMIZED')
-    rateLimitBuilds(throttle: [count: 60, durationName: 'hour', userBoost: true])
-    quietPeriod(10)
-  }
-  parameters {
-    string(name: 'VERSION', defaultValue: '', description: 'Version to dist tag.')
-    string(name: 'DIST_TAG', defaultValue: '', description: 'Dist tag to use.')
-  }
-  stages {
-    stage('Checks') {
-      options {
-        skipDefaultCheckout()
-        timeout(5)
-      }
-      steps {
-        whenTrue(isEmpty(env.VERSION)) {
-          error("VERSION cannot be empty")
-        }
-        whenTrue(isEmpty(env.DIST_TAG)) {
-          error("DIST_TAG cannot be empty")
-        }
-      }
-    }
-    stage('Checkout') {
-      options {
-        skipDefaultCheckout()
-        timeout(5)
-      }
-      steps {
-        deleteDir()
-        gitCheckout(basedir: "${BASE_DIR}")
-      }
-    }
-    stage('Dist tag') {
-      options {
-        skipDefaultCheckout()
-      }
-      steps {
-        withNodeJSEnv() {
-          dir("${BASE_DIR}") {
-            withNpmrc(secret: "${env.NPMRC_SECRET}", path: "${env.WORKSPACE}/${env.BASE_DIR}") {
-              withTotpVault(secret: "${env.TOTP_SECRET}", code_var_name: 'TOTP_CODE') {
-                sh(script: "npm dist-tag --otp=${env.TOTP_CODE} add ${env.NPM_PACKAGE}@${env.VERSION} ${env.DIST_TAG}", label: "tag ${env.DIST_TAG}")
-              }
-            }
-          }
-        }
-      }
-    }
-  }
-  post {
-    cleanup {
-      notifyBuildResult(slackComment: true)
-    }
-  }
-}
diff --git a/.ci/jobs/dist-tag-mbp.yml b/.ci/jobs/dist-tag-mbp.yml
deleted file mode 100644
index 26096cb..0000000
--- a/.ci/jobs/dist-tag-mbp.yml
+++ /dev/null
@@ -1,42 +0,0 @@
----
-- job:
-    name: apm-agent-rum/dist-tag-mbp
-    display-name: Dist tag a version for Elastic Synthetics
-    description: Dist tag a version for Elastic Synthetics.
-    project-type: multibranch
-    script-path: .ci/dist-tag.groovy
-    scm:
-      - github:
-          branch-discovery: no-pr
-          discover-pr-forks-strategy: merge-current
-          discover-pr-forks-trust: permission
-          discover-pr-origin: merge-current
-          discover-tags: false
-          disable-pr-notifications: true
-          head-filter-regex: '^(main|PR-562)$'
-          notification-context: 'elastic-synthetics'
-          repo: synthetics
-          repo-owner: elastic
-          credentials-id: 2a9602aa-ab9f-4e52-baf3-b71ca88469c7-UserAndToken
-          ssh-checkout:
-            credentials: f6c7695a-671e-4f4f-a331-acdce44ff9ba
-          build-strategies:
-            - regular-branches: true
-          property-strategies:
-            all-branches:
-              - suppress-scm-triggering: true
-          clean:
-              after: true
-              before: true
-          prune: true
-          shallow-clone: true
-          depth: 3
-          do-not-fetch-tags: true
-          submodule:
-              disable: false
-              recursive: true
-              parent-credentials: true
-              timeout: 100
-          timeout: '15'
-          use-author: true
-          wipe-workspace: true
diff --git a/.ci/jobs/synthetic-monitoring.yml b/.ci/jobs/synthetic-monitoring.yml
deleted file mode 100644
index 40b7afd..0000000
--- a/.ci/jobs/synthetic-monitoring.yml
+++ /dev/null
@@ -1,43 +0,0 @@
----
-- job:
-    name: apm-agent-rum/elastic-synthetics
-    display-name: Elastic Synthetics
-    description: Jenkins pipeline for the Elastic Synthetics project.
-    project-type: multibranch
-    script-path: .ci/Jenkinsfile
-    scm:
-      - github:
-          branch-discovery: no-pr
-          discover-pr-forks-strategy: merge-current
-          discover-pr-forks-trust: permission
-          discover-pr-origin: merge-current
-          discover-tags: true
-          head-filter-regex: '(main|\d+\.\d+\.\d+|PR-.*|v\d+\.\d+\.\d+)'
-          notification-context: 'elastic-synthetics'
-          repo: synthetics
-          repo-owner: elastic
-          credentials-id: 2a9602aa-ab9f-4e52-baf3-b71ca88469c7-UserAndToken
-          ssh-checkout:
-            credentials: f6c7695a-671e-4f4f-a331-acdce44ff9ba
-          build-strategies:
-            - tags:
-                ignore-tags-older-than: -1
-                ignore-tags-newer-than: -1
-            - regular-branches: true
-            - change-request:
-                ignore-target-only-changes: false
-          clean:
-            after: true
-            before: true
-          prune: true
-          shallow-clone: true
-          depth: 4
-          do-not-fetch-tags: true
-          submodule:
-            disable: false
-            recursive: true
-            parent-credentials: true
-            timeout: 100
-          timeout: '15'
-          use-author: true
-          wipe-workspace: 'True'
diff --git a/.github/actions/setup-totp/action.yml b/.github/actions/setup-totp/action.yml
new file mode 100644
index 0000000..861e111
--- /dev/null
+++ b/.github/actions/setup-totp/action.yml
@@ -0,0 +1,34 @@
+name: setup-totp
+description: Set up TOTP_CODE environment variable.
+inputs:
+  vault-url:
+    required: true
+    description: Vault URL
+  vault-role-id:
+    required: true
+    description: Vault Role ID
+  vault-secret-id:
+    required: true
+    description: Vault Secret ID
+  secret:
+    default: totp/code/npmjs-elasticmachine
+    description: Secret path
+  secret-key:
+    default: code
+    description: Secret key
+  env-name:
+    default: TOTP_CODE
+    description: Environment variable name
+
+runs:
+  using: composite
+  steps:
+    - name: Set up TOTP_CODE
+      uses: hashicorp/vault-action@v2.5.0
+      with:
+        url: ${{ secrets.VAULT_ADDR }}
+        roleId: ${{ secrets.VAULT_ROLE_ID }}
+        secretId: ${{ secrets.VAULT_SECRET_ID }}
+        method: approle
+        secrets: |
+          ${{ inputs.secret }} ${{ inputs.secret-key }} | ${{ inputs.env-name }};
diff --git a/.github/workflows/dist-tag.yml b/.github/workflows/dist-tag.yml
new file mode 100644
index 0000000..51327fc
--- /dev/null
+++ b/.github/workflows/dist-tag.yml
@@ -0,0 +1,34 @@
+name: dist-tag
+
+on:
+  workflow_dispatch:
+    inputs:
+      version:
+        type: string
+        description: Version to dist tag.
+        required: true
+      dist-tag:
+        type: string
+        description: Dist tag to use.
+        required: true
+
+jobs:
+  dist-tag:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: elastic/apm-pipeline-library/.github/actions/setup-npmrc@current
+        with:
+          secret: secret/jenkins-ci/npmjs/elasticmachine
+          vault-url: ${{ secrets.VAULT_ADDR }}
+          vault-role-id: ${{ secrets.VAULT_ROLE_ID }}
+          vault-secret-id: ${{ secrets.VAULT_SECRET_ID }}
+      - uses: ./.github/actions/setup-totp
+        with:
+          vault-url: ${{ secrets.VAULT_ADDR }}
+          vault-role-id: ${{ secrets.VAULT_ROLE_ID }}
+          vault-secret-id: ${{ secrets.VAULT_SECRET_ID }}
+      - name: NPM dist-tag
+        run: |
+          NPM_PACKAGE=$(jq '.name' package.json)
+          npm dist-tag --otp=${TOTP_CODE} add ${NPM_PACKAGE}@${{ github.event.inputs.version }} ${{ github.event.inputs.dist-tag }}
diff --git a/.github/workflows/opentelemetry.yml b/.github/workflows/opentelemetry.yml
index 6ea3617..b246572 100644
--- a/.github/workflows/opentelemetry.yml
+++ b/.github/workflows/opentelemetry.yml
@@ -5,7 +5,9 @@ on:
   workflow_run:
     workflows:
       - ci
+      - dist-tag
       - e2e
+      - release
     types: [completed]
 
 jobs:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..5cd31fb
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,60 @@
+name: release
+
+on:
+  workflow_dispatch:
+    inputs:
+      is-stack-release:
+        type: boolean
+        description: Should we tag the current version as a stack release?
+        default: false
+
+permissions:
+  contents: write
+
+env:
+   GH_TOKEN: ${{ github.token }}
+
+jobs:
+  release:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: elastic/apm-pipeline-library/.github/actions/setup-git@current
+      - uses: elastic/apm-pipeline-library/.github/actions/setup-npmrc@current
+        with:
+          secret: secret/jenkins-ci/npmjs/elasticmachine
+          vault-url: ${{ secrets.VAULT_ADDR }}
+          vault-role-id: ${{ secrets.VAULT_ROLE_ID }}
+          vault-secret-id: ${{ secrets.VAULT_SECRET_ID }}
+      - run: npm run clean
+      - run: npm ci
+      - name: Bump the beta version
+        run: |
+          echo "VERSION=$(npm version prerelease --preid=beta)" >> $GITHUB_ENV
+          echo "NPM_PACKAGE=$(jq '.name' package.json) >> $GITHUB_ENV
+      - run: git push --tags
+      - uses: ./.github/actions/setup-totp
+        with:
+          vault-url: ${{ secrets.VAULT_ADDR }}
+          vault-role-id: ${{ secrets.VAULT_ROLE_ID }}
+          vault-secret-id: ${{ secrets.VAULT_SECRET_ID }}
+      - name: NPM Publish
+        run: npm publish --otp=${TOTP_CODE} --tag beta
+      - name: Create 'latest' dist-tag
+        run: npm dist-tag --otp=${TOTP_CODE} add "${NPM_PACKAGE}@${VERSION}" latest
+      - name: Create 'stack_release' dist-tag
+        if: ${{ github.event.inputs.is-stack-release }}
+        run: npm dist-tag --otp=${TOTP_CODE} add "${NPM_PACKAGE}@${VERSION}" stack_release
+      - name: Create Draft Release Notes
+        run: >-
+          gh release create "${VERSION}"
+          --title="${VERSION}"
+          --generate-notes
+          --draft
+      - if: always()
+        uses: elastic/apm-pipeline-library/.github/actions/notify-build-status@current
+        with:
+          vaultUrl: ${{ secrets.VAULT_ADDR }}
+          vaultRoleId: ${{ secrets.VAULT_ROLE_ID }}
+          vaultSecretId: ${{ secrets.VAULT_SECRET_ID }}
+          slackChannel: "#synthetics-user_experience-uptime"
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4ff2e17..9a043d8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -146,17 +146,11 @@ We run end to end tests for the Synthetics agent itself, as well as the Elastic
 
 The release process is also automated in the way any specific commit from the main branch can be potentially released, for such it's required the below steps:
 
-1. Login to apm-ci.elastic.co
-1. Go to the [main](https://apm-ci.elastic.co/job/apm-agent-rum/job/elastic-synthetics/job/main/) pipeline.
-1. Click on `Build with parameters` with the below checkbox:
-
-- `release` to be selected.
-- other checkboxes should be left as default.
-
-1. Click on `Build`.
-1. Wait for an email or slack message to confirm the release is ready to be approved, it might take roughly 20 minutes.
-1. Click on the URL from the email or slack.
-1. Click on approve or abort.
+1. Go to https://github.com/elastic/synthetics/actions/workflows/release.yml
+1. Click the `Run workflow` dropdown button.
+1. Use the `main` branch option which is selected by default.
+1. Choose whether a `stack_release` dist-tag should be created or not.
+1. Click the green `Run workflow` button to trigger the release workflow.
 1. Then you can go to the `https://www.npmjs.com/package/@elastic/synthetics` to validate that the bundles have been published.
 
 ## CI
diff --git a/README.md b/README.md
index 9343fad..8ac68c1 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,5 @@
-[![Build Status](https://apm-ci.elastic.co/job/apm-agent-rum/job/elastic-synthetics/job/main/badge/icon)](https://apm-ci.elastic.co/job/apm-agent-rum/job/elastic-synthetics/job/main/)
+[![ci](https://github.com/elastic/synthetics/actions/workflows/ci.yml/badge.svg)](https://github.com/elastic/synthetics/actions/workflows/ci.yml)
+[![e2e](https://github.com/elastic/synthetics/actions/workflows/e2e.yml/badge.svg)](https://github.com/elastic/synthetics/actions/workflows/e2e.yml)
 
 # Experimental Synthetics Agent
 
diff --git a/package-lock.json b/package-lock.json
index 25b2b82..bcef62c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@elastic/synthetics",
-  "version": "1.0.0-beta.44",
+  "version": "1.0.0-beta.45",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "@elastic/synthetics",
-      "version": "1.0.0-beta.44",
+      "version": "1.0.0-beta.45",
       "license": "MIT",
       "dependencies": {
         "archiver": "^5.3.1",
diff --git a/package.json b/package.json
index 0ae9e0b..eb22c96 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@elastic/synthetics",
-  "version": "1.0.0-beta.44",
+  "version": "1.0.0-beta.45",
   "description": "Elastic synthetic monitoring agent",
   "main": "dist/index.js",
   "types": "dist/index.d.ts",
diff --git a/src/plugins/network.ts b/src/plugins/network.ts
index c7a1b1d..6ec8d0c 100644
--- a/src/plugins/network.ts
+++ b/src/plugins/network.ts
@@ -38,6 +38,22 @@ function epochTimeInSeconds() {
   return getTimestamp() / 1e6;
 }
 
+/**
+ * Find the first positive number in an array for Resource timing data
+ */
+const firstPositive = (numbers: number[]) => {
+  for (let i = 0; i < numbers.length; ++i) {
+    if (numbers[i] > 0) {
+      return numbers[i];
+    }
+  }
+  return null;
+};
+
+const roundMilliSecs = (value: number): number => {
+  return Math.floor(value * 1000) / 1000;
+};
+
 /**
  * Used as a key in each Network Request to identify the
  * associated request across distinct lifecycle events
@@ -135,7 +151,16 @@ export class NetworkManager {
       responseReceivedTime: -1,
       resourceSize: 0,
       transferSize: 0,
-      timings: null,
+      timings: {
+        blocked: -1,
+        dns: -1,
+        ssl: -1,
+        connect: -1,
+        send: -1,
+        wait: -1,
+        receive: -1,
+        total: -1,
+      },
     };
 
     if (request.redirectedFrom()) {
@@ -162,6 +187,46 @@ export class NetworkManager {
       redirectURL: networkEntry.response.redirectURL,
     };
 
+    // Gather all resource timing information up until the
+    // TTFB(Time to first byte) is received
+    const timing = request.timing();
+    const blocked =
+      roundMilliSecs(
+        firstPositive([
+          timing.domainLookupStart,
+          timing.connectStart,
+          timing.requestStart,
+        ])
+      ) || -1;
+    const dns =
+      timing.domainLookupEnd !== -1
+        ? roundMilliSecs(timing.domainLookupEnd - timing.domainLookupStart)
+        : -1;
+    const connect =
+      timing.connectEnd !== -1
+        ? roundMilliSecs(timing.connectEnd - timing.connectStart)
+        : -1;
+    const ssl =
+      timing.secureConnectionStart !== -1
+        ? roundMilliSecs(timing.connectEnd - timing.secureConnectionStart)
+        : -1;
+    const wait =
+      timing.responseStart !== -1
+        ? roundMilliSecs(timing.responseStart - timing.requestStart)
+        : -1;
+
+    networkEntry.timings = {
+      blocked,
+      dns,
+      ssl,
+      connect,
+      send: 0, // not exposed via RT api
+      wait,
+      receive: -1, // will be available after full response is received
+      total: -1,
+    };
+    this._calcTotalTime(networkEntry, timing);
+
     const page = request.frame().page();
     this._addBarrier(
       page,
@@ -200,83 +265,21 @@ export class NetworkManager {
     if (!networkEntry) return;
 
     networkEntry.loadEndTime = epochTimeInSeconds();
+    // responseEnd is fired after the last byte of the response is received.
     const timing = request.timing();
-    const { loadEndTime, requestSentTime } = networkEntry;
-    networkEntry.timings = {
-      blocked: -1,
-      dns: -1,
-      ssl: -1,
-      connect: -1,
-      send: -1,
-      wait: -1,
-      receive: -1,
-      total: -1,
-    };
-
-    const firstPositive = (numbers: number[]) => {
-      for (let i = 0; i < numbers.length; ++i) {
-        if (numbers[i] > 0) {
-          return numbers[i];
-        }
-      }
-      return null;
-    };
-    const roundMilliSecs = (value: number): number => {
-      return Math.floor(value * 1000) / 1000;
-    };
-
-    if (timing.startTime === 0) {
-      // Convert to milliseconds before round off
-      const total = roundMilliSecs((loadEndTime - requestSentTime) * 1000);
-      networkEntry.timings.total = total;
-      return;
-    }
-
-    const blocked =
-      roundMilliSecs(
-        firstPositive([
-          timing.domainLookupStart,
-          timing.connectStart,
-          timing.requestStart,
-        ])
-      ) || -1;
-    const dns =
-      timing.domainLookupEnd !== -1
-        ? roundMilliSecs(timing.domainLookupEnd - timing.domainLookupStart)
-        : -1;
-    const connect =
-      timing.connectEnd !== -1
-        ? roundMilliSecs(timing.connectEnd - timing.connectStart)
-        : -1;
-    const ssl =
-      timing.secureConnectionStart !== -1
-        ? roundMilliSecs(timing.connectEnd - timing.secureConnectionStart)
-        : -1;
-    const wait =
-      timing.responseStart !== -1
-        ? roundMilliSecs(timing.responseStart - timing.requestStart)
-        : -1;
     const receive =
       timing.responseEnd !== -1
         ? roundMilliSecs(timing.responseEnd - timing.responseStart)
         : -1;
-    const total = [blocked, dns, connect, wait, receive].reduce(
-      (pre, cur) => (cur > 0 ? cur + pre : pre),
-      0
-    );
-    networkEntry.timings = {
-      blocked,
-      dns,
-      connect,
-      ssl,
-      wait,
-      send: 0, // not exposed via RT api
-      receive,
-      total,
-    };
+    networkEntry.timings.receive = receive;
+    this._calcTotalTime(networkEntry, timing);
 
-    const page = request.frame().page();
     // For aborted/failed requests sizes will not be present
+    if (timing.startTime <= 0) {
+      return;
+    }
+
+    const page = request.frame().page();
     this._addBarrier(
       page,
       request.sizes().then(sizes => {
@@ -295,6 +298,35 @@ export class NetworkManager {
     );
   }
 
+  /**
+   * Calculates the total time for the network request based on the ResourceTiming
+   * data from Playwright. Fallbacks to the event timings if ResourceTiming data
+   * is not available.
+   */
+  private _calcTotalTime(
+    entry: NetworkInfo,
+    rtiming: ReturnType<Request['timing']>
+  ) {
+    const timings = entry.timings;
+    entry.timings.total = [
+      timings.blocked,
+      timings.dns,
+      timings.connect,
+      timings.wait,
+      timings.receive,
+    ].reduce((pre, cur) => ((cur || -1) > 0 ? cur + pre : pre), 0);
+
+    // fallback when ResourceTiming data is not available
+    if (rtiming.startTime <= 0) {
+      const end =
+        entry.loadEndTime ||
+        entry.responseReceivedTime ||
+        entry.requestSentTime;
+      const total = roundMilliSecs((end - entry.requestSentTime) * 1000);
+      entry.timings.total = total <= 0 ? -1 : total;
+    }
+  }
+
   async stop() {
     /**
      * Waiting for all network events is error prone and might hang the tests


Test patch:
diff --git a/__tests__/plugins/network.test.ts b/__tests__/plugins/network.test.ts
index f1281fe..134502c 100644
--- a/__tests__/plugins/network.test.ts
+++ b/__tests__/plugins/network.test.ts
@@ -287,6 +287,36 @@ describe('network', () => {
     });
   });
 
+  it('timings for broken response', async () => {
+    const driver = await Gatherer.setupDriver({
+      wsEndpoint,
+    });
+    const network = new NetworkManager(driver);
+    await network.start();
+
+    server.route('/index', async (_, res) => {
+      res.setHeader('content-type', 'text/html');
+      await delay(10);
+      res.write(`<head></head>`);
+      await delay(500);
+      res.end(`<body></body>`);
+    });
+
+    await driver.page
+      .goto(server.PREFIX + '/index', { timeout: 100 })
+      .catch(() => {});
+
+    const netinfo = await network.stop();
+    await Gatherer.stop();
+
+    expect(netinfo.length).toBe(1);
+    // responseEnd is never fired as we are not waiting for the
+    // `responseFinished` event instead cutting off after TTFB
+    expect(netinfo[0].timings.receive).toEqual(-1);
+    expect(netinfo[0].timings.total).toBeGreaterThan(10);
+    expect(netinfo[0].timings.total).toBeLessThan(500);
+  });
+
   it("doesn't capture network info from request context", async () => {
     const driver = await Gatherer.setupDriver({
       wsEndpoint,