Last active
February 19, 2022 16:13
-
-
Save ak--47/80a5065787f484574596e6e0e6af7555 to your computer and use it in GitHub Desktop.
Github Webhooks => Mixpanel Annotations
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// GitHub to Mixpanel Annotations | |
// by AK | |
// ak@mixpanel.com | |
// purpose: ingest github webhooks; create Mixpanel annotations | |
//deps | |
const crypto = require('crypto'); | |
const http = require("https"); | |
/* | |
! IMPORTANT NOTE ! | |
it is NOT a good practice to store secrets in code as we have done here | |
creds are just here for ease of illustration | |
the best way to store secrets in a cloud function is to use Google's secret manager | |
https://cloud.google.com/functions/docs/configuring/secrets | |
*/ | |
const creds = { | |
github: `{{ your github secret }}`, | |
mpUser: `{{ your mixpanel service account user }}`, | |
mpSecret: `{{ your mixpanel service account secret }}`, | |
workspace_id: `{{ your mixpanel workspace_id }}` | |
} | |
//entry point for cloud function | |
exports.start = async (req, res) => { | |
console.log('RUNNING!') | |
//grab body + sig | |
const body = req.body | |
const sig = req.headers['x-hub-signature-256'] | |
//run our program | |
await main(body, sig).then((response) => { | |
res.status(200).send(response); | |
console.log('FINISHED!') | |
}) | |
}; | |
async function main(body = {}, sig = ``) { | |
//verify github signature! | |
const isVerified = verifyPostData(sig, body, creds.github) | |
if (isVerified) { | |
const annotationData = { | |
date: ``, | |
description: `${body.release.tag_name} - ${body.release.name}` | |
} | |
//make the date match mixpanel's format and add time | |
const published_at = new Date(body.release.published_at); | |
const UTCOffset = 5 //EST | |
published_at.setHours(published_at.getHours() + UTCOffset); | |
const dateTimeString = `${published_at.getFullYear().toString().padStart(4, '0')}-${(published_at.getMonth()+1).toString().padStart(2, '0')}-${published_at.getDate().toString().padStart(2, '0')} ${published_at.getHours().toString().padStart(2, '0')}:${published_at.getMinutes().toString().padStart(2, '0')}:${published_at.getSeconds().toString().padStart(2, '0')}`; | |
annotationData.date = dateTimeString; | |
//create the annotation | |
const response = await makeAnnotation(annotationData, creds) | |
if (response.statusCode === 200) { | |
console.log(`created annotation!`) | |
return `👍` | |
} else { | |
console.log(`failed to create annotation!`) | |
return `👎` | |
} | |
} else { | |
console.log(`message was not signed correctly!`) | |
return `👎` | |
} | |
} | |
//UTILITIES | |
//verify github actually sent the message | |
function verifyPostData(signature, body, secret) { | |
const sigHeaderName = 'X-Hub-Signature-256' | |
const sigHashAlg = 'sha256' | |
const sig = Buffer.from(signature || '', 'utf8') | |
const hmac = crypto.createHmac(sigHashAlg, secret) | |
const digest = Buffer.from(sigHashAlg + '=' + hmac.update(JSON.stringify(body)).digest('hex'), 'utf8') | |
if (sig.length !== digest.length || !crypto.timingSafeEqual(digest, sig)) { | |
return false; | |
} else { | |
return true; | |
} | |
} | |
//call the annotations API | |
async function makeAnnotation(data, creds) { | |
const auth = Buffer.from(`${creds.mpUser}:${creds.mpSecret}`).toString('base64') | |
const options = { | |
hostname: 'mixpanel.com', | |
path: `/api/app/workspaces/${creds.workspace_id}/annotations`, | |
method: 'POST', | |
headers: { | |
Accept: 'application/json', | |
Authorization: `Basic ${auth}` | |
} | |
}; | |
const response = await fetch(options, data) | |
return response; | |
} | |
//promise-aware version of fetch() using http.request | |
function fetch(params, postData) { | |
return new Promise(function(resolve, reject) { | |
const req = http.request(params, function(res) { | |
// reject on bad status | |
if (res.statusCode < 200 || res.statusCode >= 300) { | |
return reject(new Error('statusCode=' + res.statusCode)); | |
} | |
// cumulate data | |
let body = []; | |
res.on('data', function(chunk) { | |
body.push(chunk); | |
}); | |
// resolve on end | |
res.on('end', function() { | |
try { | |
body = JSON.parse(Buffer.concat(body).toString()); | |
} catch (e) { | |
reject(e); | |
} | |
resolve(body); | |
}); | |
}); | |
// reject on request error | |
req.on('error', function(err) { | |
// This is not a "Second reject", just a different sort of failure | |
reject(err); | |
}); | |
if (postData) { | |
req.write(postData); | |
} | |
// IMPORTANT | |
req.end(); | |
}); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment