<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>Lunaxod</title><link>https://lunaxod.com/</link><description>Notes by Oleksandr Maksymeniuk, web analyst and founder of Ringostat.</description><item><title>Building GTM Server side client</title><link>https://lunaxod.com/building-gtm-server-side-client/</link><guid>https://lunaxod.com/building-gtm-server-side-client/</guid><pubDate>Tue, 25 May 2021 00:00:00 +0000</pubDate><description>&lt;p&gt;Google some time ago created a great new instrument. New &lt;a href=&quot;https://developers.google.com/tag-manager/serverside&quot;&gt;Google Tag Manager Server-side&lt;/a&gt;, there is a lot of information on why it&amp;#8217;s so cool and what it can help. So I won&amp;#8217;t stop on this much.&lt;/p&gt;

&lt;p&gt;The summary is that it&amp;#8217;s a new type of tagging that runs on a Google server (now not only Google) and can run different tags on each call, improving them with some data that is not cool to keep on the front end, e.g.,, profit on the transaction. Also, it runs on your subdomain, so AdBlocks hard to get so that you will track most of the users. And you can deal with Safari’s &lt;a href=&quot;https://www.cookiestatus.com/safari/&quot;&gt;Intelligent Tracking Prevention&lt;/a&gt; from capping its expiration to just 7 days.&lt;/p&gt;

&lt;p&gt;Today I want to share how I built a client that works mostly like a smart proxy. I built this client for the Ringostat script, but you can use it the same way for any other tracking.&lt;/p&gt;

&lt;p&gt;Ringostat script implements call tracking, callback, and end-to-end analytics functions, so depending on which products are used in the project, it calls for up to 10 endpoints on 5 domains. Yeah, that&amp;#8217;s huge but interesting in terms of this post.&lt;/p&gt;

&lt;p&gt;Because it has call tracking and callback, we need to provide frontend responses with tracking numbers, callback forms on required language with exact CSS, etc., so we need a client, not a tag. The client should be simple and implement some additional functions: add client IP header and client User-Agent to a call to Ringostat platform, because if you won&amp;#8217;t, all traffic will come from the IP of your cloud server, and all logic that relies on this params will be broken.&lt;/p&gt;

&lt;p&gt;Also, we need to update long-term cookies to server-provided ones. And fix the CORS to make them the best.&lt;/p&gt;

&lt;p&gt;So lets go:&lt;/p&gt;

&lt;p&gt;The first step is to set up a server-side container, mostly go through step-by-step instructions from Google. Besides one detail for tracking subdomain, we&amp;#8217;ll use A/AAAA records in DNS, not CNAME (got this from &lt;a href=&quot;https://www.simoahava.com/gtm-tips/build-custom-universal-analytics-client-server-side-tagging/&quot;&gt;Simo Ahava&amp;#8217;s article&lt;/a&gt;). Also, I found most of the information on his blog and GitHub repo.&lt;/p&gt;

&lt;p&gt;Then we need to create a new template. Fill the first page, it&amp;#8217;s not so necessary while you create a client for yourself, but I made mine for the public.&lt;/p&gt;

&lt;figure&gt;&lt;a href=&quot;/wp-content/uploads/2021/04/GTM-Server-Side-Info.png&quot;&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;676&quot; height=&quot;525&quot; src=&quot;/wp-content/uploads/2021/04/GTM-Server-Side-Info.png&quot; alt=&quot;&quot; srcset=&quot;/wp-content/uploads/2021/04/GTM-Server-Side-Info.png 676w, /wp-content/uploads/2021/04/GTM-Server-Side-Info-300x233.png 300w&quot; sizes=&quot;auto, (max-width: 676px) 100vw, 676px&quot; /&gt;&lt;/a&gt;&lt;/figure&gt;

&lt;p&gt;The next page: &amp;#8220;Fields&amp;#8221; could be necessary for you if you&amp;#8217;ll make a client for the public and need to set some variables. Later you can use them in code calling the data object.&lt;/p&gt;

&lt;p&gt;And lets go to Code tab!&lt;/p&gt;

&lt;p&gt;First of all, here used Sandboxed JavaScript, and if you are trying to deal with it for the first time, it&amp;#8217;s quite strange, but it&amp;#8217;s what we have to use. I think they made this way to achieve two goals to make code more straight so you can&amp;#8217;t hide some evil in it, and to achieve performance, you can&amp;#8217;t use RegExp, ReplaceAll, and a lot of other functions. Also, you have to register all the APIs you will use and also fill the permissions tab.&lt;/p&gt;

&lt;p&gt;The result template you can find on our &lt;a href=&quot;https://github.com/ringostat/Ringostat-GTM-Server-side-client/blob/master/template.tpl&quot;&gt;GitHub&lt;/a&gt;, and here is content of code tab:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;const getRequestPath = require(&#x27;getRequestPath&#x27;);
if (getRequestPath().substring(0,4) !== &#x27;/rng&#x27;) return;
const claimRequest = require(&#x27;claimRequest&#x27;);
claimRequest();
const getCookie = require(&#x27;getCookieValues&#x27;);
const getRemoteAddress = require(&#x27;getRemoteAddress&#x27;);
const getRequestHeader = require(&#x27;getRequestHeader&#x27;);
const returnResponse = require(&#x27;returnResponse&#x27;);
const setCookie = require(&#x27;setCookie&#x27;);
const getRequestBody = require(&#x27;getRequestBody&#x27;);
const sendHttpRequest = require(&#x27;sendHttpRequest&#x27;);
const setResponseBody = require(&#x27;setResponseBody&#x27;);
const setResponseHeader = require(&#x27;setResponseHeader&#x27;);
const setResponseStatus = require(&#x27;setResponseStatus&#x27;);
const getRequestQueryString = require(&#x27;getRequestQueryString&#x27;);
const getRequestMethod = require(&#x27;getRequestMethod&#x27;);
const encodeUriComponent = require(&#x27;encodeUriComponent&#x27;);
const getCookieValues = require(&#x27;getCookieValues&#x27;);
const JSON = require(&#x27;JSON&#x27;);

const sameSiteOption = (data.sameSiteOption) ? data.sameSiteOption : &#x27;none&#x27;;
const cookieDomain = (data.cookieDomain) ? data.cookieDomain : &#x27;auto&#x27;;
let projectHash = data.projectHash;
let origin = getRequestHeader(&#x27;Origin&#x27;);
let ua = getRequestHeader(&#x27;user-agent&#x27;);
let ip = getRemoteAddress();
let requestDir = getRequestPath().substring(4,8);

const forwardUrl = () =&gt; {
  let endpoint = getRequestPath().substring(7);
  let queryPath =&#x27;?ip_override=&#x27; + encodeUriComponent(ip)+&#x27;&amp;amp;ua_override=&#x27; + encodeUriComponent(ua);
  endpoint += queryPath;
  if (getRequestQueryString()) endpoint += &#x27;&amp;amp;&#x27;+getRequestQueryString();
  return endpoint;
};

const setResponseHeaders = (headers) =&gt; {
  for (const key in headers) {
    if (key !== &#x27;access-control-allow-origin&#x27; &amp;amp;&amp;amp; key !== &#x27;set-cookie&#x27;) setResponseHeader(key, headers&amp;#91;key]);
  }
  setResponseHeader(&#x27;Access-Control-Allow-Origin&#x27;, (origin) ? origin : &#x27;*&#x27;);
  setResponseHeader(&#x27;Access-Control-Allow-Credentials&#x27;, &#x27;true&#x27;);
};
const proxyResponse = (response, headers, statusCode) =&gt; {
  setResponseStatus(statusCode);
  setResponseBody(response);
  setResponseHeaders(headers);
  if (headers&amp;#91;&#x27;set-cookie&#x27;])
    for (let i=0; i&amp;lt;headers&amp;#91;&#x27;set-cookie&#x27;].length; i++) {
      let cook = headers&amp;#91;&#x27;set-cookie&#x27;]&amp;#91;i].split(&quot;;&quot;);
      let name = cook&amp;#91;0].split(&quot;=&quot;)&amp;#91;0];
      let value = cook&amp;#91;0].split(&quot;=&quot;)&amp;#91;1];
      setCookie(name, value, {
        domain: cookieDomain,
        &#x27;max-age&#x27;: 63072000,
        path: &#x27;/&#x27;,
        secure: true,
        sameSite: sameSiteOption
      });
    }
  const rngst = getCookie(&#x27;rngst&#x27;);
  if (rngst &amp;amp;&amp;amp; rngst.length) {
    setCookie(&#x27;rngst&#x27;, rngst&amp;#91;0], {
      domain: cookieDomain,
      &#x27;max-age&#x27;: 63072000,
      path: &#x27;/&#x27;,
      secure: true,
      sameSite: sameSiteOption
    });
  }
  const rngst2 = getCookie(&#x27;rngst2&#x27;);
  if (rngst2 &amp;amp;&amp;amp; rngst2.length) {
    setCookie(&#x27;rngst2&#x27;, rngst2&amp;#91;0], {
      domain: cookieDomain,
      &#x27;max-age&#x27;: 63072000,
      path: &#x27;/&#x27;,
      secure: true,
      sameSite: sameSiteOption
    });
  }

  returnResponse();
};

if (getRequestPath() === &#x27;/rng/ap/ipinfo&#x27;) {
  let ipinfo = {};
  ipinfo.ip = ip;
  ipinfo.city = getRequestHeader(&#x27;X-Appengine-City&#x27;);
  ipinfo.country = getRequestHeader(&#x27;X-Appengine-Country&#x27;);
  ipinfo.loc = getRequestHeader(&#x27;X-Appengine-Citylatlong&#x27;);
  setResponseHeaders({});
  setResponseBody(JSON.stringify(ipinfo));
  returnResponse();
}

let requestDomain;
switch (requestDir) {
  case &#x27;/v4/&#x27;:
    let SCRIPT_SRC = &#x27;/v4/&#x27; + encodeUriComponent(projectHash.substring(0, 2)) + &#x27;/&#x27; + encodeUriComponent(projectHash) + &#x27;.js&#x27;;
    if (getRequestPath().substring(4) === SCRIPT_SRC) {
      requestDomain = &#x27;https://script.ringostat.com/v4&#x27;;
    }
    break;
  case &#x27;/ct/&#x27;:
    requestDomain = &#x27;https://analytics.ringostat.net&#x27;;
    break;
  case &#x27;/cb/&#x27;:
    requestDomain = &#x27;https://substitution.ringostat.net&#x27;;
    break;
  case &#x27;/bk/&#x27;:
    requestDomain = &#x27;https://app.ringostat.com&#x27;;
    break;
  case &#x27;/ap/&#x27;:
    requestDomain = &#x27;https://api.ringostat.com&#x27;;
    break;
  default:
    return;
}

let headers = {};
if (getRequestHeader(&#x27;Content-Type&#x27;)) headers&amp;#91;&#x27;Content-Type&#x27;] = getRequestHeader(&#x27;Content-Type&#x27;);
if (getRequestHeader(&#x27;Accept-Language&#x27;)) headers&amp;#91;&#x27;Accept-Language&#x27;] = getRequestHeader(&#x27;Accept-Language&#x27;);
if (getRequestHeader(&#x27;Cache-Control&#x27;)) headers&amp;#91;&#x27;Cache-Control&#x27;] = getRequestHeader(&#x27;Cache-Control&#x27;);
if (getRequestHeader(&#x27;upgrade-insecure-requests&#x27;)) headers&amp;#91;&#x27;upgrade-insecure-requests&#x27;] = getRequestHeader(&#x27;upgrade-insecure-requests&#x27;);
if (getRequestHeader(&#x27;User-Agent&#x27;)) headers&amp;#91;&#x27;User-Agent&#x27;] = getRequestHeader(&#x27;User-Agent&#x27;);
if (getRequestHeader(&#x27;Accept&#x27;)) headers.Accept = getRequestHeader(&#x27;Accept&#x27;);
if (getCookieValues(&#x27;ringo_hasCall&#x27;)) {
  
  let cookies = &#x27;ringo_hasCall=&#x27;+getCookieValues(&#x27;ringo_hasCall&#x27;,true)&amp;#91;0]+&#x27;;&#x27;;
  cookies += &#x27;ringo_calldate=&#x27;+getCookieValues(&#x27;ringo_calldate&#x27;,true)&amp;#91;0]+&#x27;;&#x27;;
  cookies += &#x27;ringo_userfield=&#x27;+getCookieValues(&#x27;ringo_userfield&#x27;,true)&amp;#91;0]+&#x27;;&#x27;;
  cookies += &#x27;ringo_hash=&#x27;+getCookieValues(&#x27;ringo_hash&#x27;,true)&amp;#91;0]+&#x27;;&#x27;;  
  headers.Cookie = cookies;
}

let url = requestDomain+forwardUrl();
sendHttpRequest(url, (statusCode, headers, body) =&gt; {
  proxyResponse(body,headers,statusCode);
}, {headers: headers, method: getRequestMethod(), timeout: 10000}, getRequestBody());&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Let&amp;#8217;s see what we have here step by step:&lt;/p&gt;

&lt;p&gt;We need to run clients for only some urls, not waste server resources and not do unnecessary calls. First of all, I decided to add getRequestPath API, and after that, I check that the URL contains the path I will call. I decided to add &amp;#8220;/rng&amp;#8221; to each call that my client should work with. So if the request path starts with &amp;#8220;/rng&amp;#8221;, we claim the request and move forward. All other clients wouldn&amp;#8217;t start after that.&lt;/p&gt;

&lt;p&gt;If URL doesn&amp;#8217;t starts with &amp;#8220;/rng&amp;#8221; this client will not process it else — claim request and move forward.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;const getRequestPath = require(&#x27;getRequestPath&#x27;);
if (getRequestPath().substring(0,4) !== &#x27;/rng&#x27;) return;
const claimRequest = require(&#x27;claimRequest&#x27;);
claimRequest();&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Next, I added all required APIs. Also, some additional during debugging like &amp;#8220;logToConsole&amp;#8221; that I deleted for the production version. We&amp;#8217;ll speak about logging later.&lt;/p&gt;

&lt;p&gt;On the fields tab, I added some settings: projectHash, sameSiteOption, cookieDomain to have the ability to set this. And we&amp;#8217;ll need the origin, userAgent, IP of a customer, and requested method, that I get from url. So in code, we read this setting and variables to process later:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;const sameSiteOption = (data.sameSiteOption) ? data.sameSiteOption : &#x27;none&#x27;;
const cookieDomain = (data.cookieDomain) ? data.cookieDomain : &#x27;auto&#x27;;
let projectHash = data.projectHash;
let origin = getRequestHeader(&#x27;Origin&#x27;);
let ua = getRequestHeader(&#x27;user-agent&#x27;);
let ip = getRemoteAddress();
let requestDir = getRequestPath().substring(4,8);&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;After that we add functions:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;const forwardUrl = () =&gt; {
  let endpoint = getRequestPath().substring(7);
  let queryPath =&#x27;?ip_override=&#x27; + encodeUriComponent(ip)+&#x27;&amp;amp;ua_override=&#x27; + encodeUriComponent(ua);
  endpoint += queryPath;
  if (getRequestQueryString()) endpoint += &#x27;&amp;amp;&#x27;+getRequestQueryString();
  return endpoint;
};&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Makes url from original and adds IP and UserAgent params.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;const setResponseHeaders = (headers) =&gt; {
  for (const key in headers) {
    if (key !== &#x27;access-control-allow-origin&#x27; &amp;amp;&amp;amp; key !== &#x27;set-cookie&#x27;) setResponseHeader(key, headers&amp;#91;key]);
  }
  setResponseHeader(&#x27;Access-Control-Allow-Origin&#x27;, (origin) ? origin : &#x27;*&#x27;);
  setResponseHeader(&#x27;Access-Control-Allow-Credentials&#x27;, &#x27;true&#x27;);
};&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Sets the response headers from original response headers except &amp;#8216;access-control-allow-origin&amp;#8217; that we add also but more strict and set-cookie because this header has limitations in server-side so we can&amp;#8217;t use it as is.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;const proxyResponse = (response, headers, statusCode) =&gt; {
  setResponseStatus(statusCode);
  setResponseBody(response);
  setResponseHeaders(headers);
  if (headers&amp;#91;&#x27;set-cookie&#x27;])
    for (let i=0; i&amp;lt;headers&amp;#91;&#x27;set-cookie&#x27;].length; i++) {
      let cook = headers&amp;#91;&#x27;set-cookie&#x27;]&amp;#91;i].split(&quot;;&quot;);
      let name = cook&amp;#91;0].split(&quot;=&quot;)&amp;#91;0];
      let value = cook&amp;#91;0].split(&quot;=&quot;)&amp;#91;1];
      setCookie(name, value, {
        domain: cookieDomain,
        &#x27;max-age&#x27;: 63072000,
        path: &#x27;/&#x27;,
        secure: true,
        sameSite: sameSiteOption
      });
    }
  const rngst = getCookie(&#x27;rngst&#x27;);
  if (rngst &amp;amp;&amp;amp; rngst.length) {
    setCookie(&#x27;rngst&#x27;, rngst&amp;#91;0], {
      domain: cookieDomain,
      &#x27;max-age&#x27;: 63072000,
      path: &#x27;/&#x27;,
      secure: true,
      sameSite: sameSiteOption
    });
  }
  const rngst2 = getCookie(&#x27;rngst2&#x27;);
  if (rngst2 &amp;amp;&amp;amp; rngst2.length) {
    setCookie(&#x27;rngst2&#x27;, rngst2&amp;#91;0], {
      domain: cookieDomain,
      &#x27;max-age&#x27;: 63072000,
      path: &#x27;/&#x27;,
      secure: true,
      sameSite: sameSiteOption
    });
  }

  returnResponse();
};&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The main function that will be called on callback of sendHttpRequest. This function works with its response and responds that we&amp;#8217;ll get on the front-end.&lt;/p&gt;

&lt;p&gt;Here we also work with set-cookie response header and repack each of them in different headers. That was the only way I made it work.&lt;/p&gt;

&lt;p&gt;Also, we read two cookies that should have a long time valid period and return them as server-side. In the script, we had to add an option only to create them and not prolong them.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;if (getRequestPath() === &#x27;/rng/ap/ipinfo&#x27;) {
let ipinfo = {};
ipinfo.ip = ip;
ipinfo.city = getRequestHeader(&#x27;X-Appengine-City&#x27;);
ipinfo.country = getRequestHeader(&#x27;X-Appengine-Country&#x27;);
ipinfo.loc = getRequestHeader(&#x27;X-Appengine-Citylatlong&#x27;);
setResponseHeaders({});
setResponseBody(JSON.stringify(ipinfo));
returnResponse();
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;We have an API that returns JSON with location info based on IP, but I decided to change this and use native resolve from GTM server-side. Why not?&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;let requestDomain;
switch (requestDir) {
  case &#x27;/v4/&#x27;:
    let SCRIPT_SRC = &#x27;/v4/&#x27; + encodeUriComponent(projectHash.substring(0, 2)) + &#x27;/&#x27; + encodeUriComponent(projectHash) + &#x27;.js&#x27;;
    if (getRequestPath().substring(4) === SCRIPT_SRC) {
      requestDomain = &#x27;https://script.ringostat.com/v4&#x27;;
    }
    break;
  case &#x27;/ct/&#x27;:
    requestDomain = &#x27;https://analytics.ringostat.net&#x27;;
    break;
  case &#x27;/cb/&#x27;:
    requestDomain = &#x27;https://substitution.ringostat.net&#x27;;
    break;
  case &#x27;/bk/&#x27;:
    requestDomain = &#x27;https://app.ringostat.com&#x27;;
    break;
  case &#x27;/ap/&#x27;:
    requestDomain = &#x27;https://api.ringostat.com&#x27;;
    break;
  default:
    return;
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Depending on the requested directory, we select the endpoint host on Ringostat. Yes, it&amp;#8217;s not so cool that we have multiple subdomains for each part of code, but in this article, I don&amp;#8217;t aim to simplify.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;let headers = {};
if (getRequestHeader(&#x27;Content-Type&#x27;)) headers&amp;#91;&#x27;Content-Type&#x27;] = getRequestHeader(&#x27;Content-Type&#x27;);
if (getRequestHeader(&#x27;Accept-Language&#x27;)) headers&amp;#91;&#x27;Accept-Language&#x27;] = getRequestHeader(&#x27;Accept-Language&#x27;);
if (getRequestHeader(&#x27;Cache-Control&#x27;)) headers&amp;#91;&#x27;Cache-Control&#x27;] = getRequestHeader(&#x27;Cache-Control&#x27;);
if (getRequestHeader(&#x27;upgrade-insecure-requests&#x27;)) headers&amp;#91;&#x27;upgrade-insecure-requests&#x27;] = getRequestHeader(&#x27;upgrade-insecure-requests&#x27;);
if (getRequestHeader(&#x27;User-Agent&#x27;)) headers&amp;#91;&#x27;User-Agent&#x27;] = getRequestHeader(&#x27;User-Agent&#x27;);
if (getRequestHeader(&#x27;Accept&#x27;)) headers.Accept = getRequestHeader(&#x27;Accept&#x27;);
if (getCookieValues(&#x27;ringo_hasCall&#x27;)) {
  
  let cookies = &#x27;ringo_hasCall=&#x27;+getCookieValues(&#x27;ringo_hasCall&#x27;,true)&amp;#91;0]+&#x27;;&#x27;;
  cookies += &#x27;ringo_calldate=&#x27;+getCookieValues(&#x27;ringo_calldate&#x27;,true)&amp;#91;0]+&#x27;;&#x27;;
  cookies += &#x27;ringo_userfield=&#x27;+getCookieValues(&#x27;ringo_userfield&#x27;,true)&amp;#91;0]+&#x27;;&#x27;;
  cookies += &#x27;ringo_hash=&#x27;+getCookieValues(&#x27;ringo_hash&#x27;,true)&amp;#91;0]+&#x27;;&#x27;;  
  headers.Cookie = cookies;
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Okay, while we have the ability to read almost all response headers and transmit them to the front-end with requests, we have to list all required headers. So here, we form an object with a list of request headers that the endpoint needs to add to the end party request.&lt;/p&gt;

&lt;p&gt;The cookies header works differently. To get it, we have to use another API — &amp;#8220;getCookieValues&amp;#8221; and form the header manually. Also, you have to know exactly which cookies you need and check whether they are present.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;let url = requestDomain+forwardUrl();
sendHttpRequest(url, (statusCode, headers, body) =&gt; {
  proxyResponse(body,headers,statusCode);
}, {headers: headers, method: getRequestMethod(), timeout: 10000}, getRequestBody());&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And finally! We form url of the Ringostat endpoint that we need to call. With &amp;#8220;sendHttpRequest&amp;#8221;, we make a call to that endpoint, sending headers that we repacked from the original request, using the same method, and body, with a timeout. In the callback function, we call the &amp;#8220;proxyResponse&amp;#8221; function that we created previously.&lt;/p&gt;

&lt;p&gt;The next step will be to go to the permissions tab and add all the required permissions for this tag to start working. I won&amp;#8217;t tell a lot about this only stop is cookies permissions, where you have to list all the cookies that you will read in this client.&lt;/p&gt;

&lt;p&gt;After that, we can save this template and add the client of this new type in the &amp;#8220;client&amp;#8221; section.&lt;/p&gt;

&lt;p&gt;I didn&amp;#8217;t say anything about debugging during this tutorial. I plan to do that in the next article.&lt;/p&gt;
</description></item><item><title>Bulk withdrawal LinkedIn old pending invitation</title><link>https://lunaxod.com/mass-withdrawal-linkedin-old-pending-invitation/</link><guid>https://lunaxod.com/mass-withdrawal-linkedin-old-pending-invitation/</guid><pubDate>Thu, 05 Nov 2020 00:00:00 +0000</pubDate><description>&lt;p&gt;UPD 11/19/2020: Due to changes in interface previous version doesn&amp;#8217;t works properly, so had to update code, with delay 1 second for each withdrawal.&lt;/p&gt;

&lt;p&gt;At some moment amount of pending invitations becomes too huge. That causes problems with sending new invites. So you have to withdraw some of them that are too old.&lt;/p&gt;

&lt;p&gt;You can do i manually, but that takes too long even, when you have to withdraw 50 invitations but at some moment I had 3500+ of them.&lt;/p&gt;

&lt;p&gt;As an SEO specialist, I used many bookmarklets (bookmarks that contain a snippet of JavaScript). So I decided to do my bookmarklet that will click the withdraw button and then confirm.&lt;/p&gt;

&lt;p&gt;Updated script takes all withdrawal buttons, click on them with 1 second delay from end of the page to the start. With little delay clicks on the confirmation button. After all invitations on the page withdrawn it starts from the end till the moment when there would be no pending invitations from after current page. The script:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;javascript:
    var i = document.querySelectorAll(&quot;button&amp;#91;data-control-name=withdraw_single]&quot;).length - 1; //set index to the last withdraw button on page
var interval = setInterval( //set interval for repeat function till the end of pending invitations
    function() {
        if (location.search.match(/page=/)) { //check that we werent redirected to the first page of pending requests
            document.querySelectorAll(&quot;button&amp;#91;data-control-name=withdraw_single]&quot;)&amp;#91;i].click(); //click on withdraw button
            i = ((i &amp;lt; 1 || i &gt; document.querySelectorAll(&quot;button&amp;#91;data-control-name=withdraw_single]&quot;).length - 1) ? document.querySelectorAll(&quot;button&amp;#91;data-control-name=withdraw_single]&quot;).length - 1 : i - 1); //decrement index or if it reached 0 restart from the end
            document.querySelector(&quot;.artdeco-button--primary&quot;).click(); //click on confirmation button
        } else {
            clearInterval(interval); //in case when we were redirected to the first page stop end interval
        }
    }, 1000); //interval set to 1000 ms (1 seconds)&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The same but in one line:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;javascript:var i = document.querySelectorAll(&quot;button&amp;#91;data-control-name=withdraw_single]&quot;).length-1; var interval = setInterval(function() {if (location.search.match(/page=/)) {document.querySelectorAll(&quot;button&amp;#91;data-control-name=withdraw_single]&quot;)&amp;#91;i].click();i = ((i&amp;lt;1||i&gt;document.querySelectorAll(&quot;button&amp;#91;data-control-name=withdraw_single]&quot;).length-1)?document.querySelectorAll(&quot;button&amp;#91;data-control-name=withdraw_single]&quot;).length-1:i-1);document.querySelector(&quot;.artdeco-button--primary&quot;).click();} else {clearInterval(interval)}},1000);&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;You can add this code to a bookmark, to do that: &lt;/p&gt;

&lt;ul&gt;&lt;li&gt;In Chrome, click Bookmarks-&amp;gt;Bookmark Manager.&lt;/li&gt;&lt;li&gt;You should see a new tab with the bookmarks and folders listed.&lt;/li&gt;&lt;li&gt;Select the “Bookmarks Tab” folder on the left.&lt;/li&gt;&lt;li&gt;Click the “Organize” link, then “Add Page” in the dropdown.&lt;/li&gt;&lt;li&gt;You should see two input fields. Type the name of the bookmark you would like (i.e., WithdrawPendingLinkedIn) in the first field.&lt;/li&gt;&lt;li&gt;Paste the javascript code upper into the second field.&lt;/li&gt;&lt;/ul&gt;

&lt;p&gt;Or try to right-click on &lt;a href=&quot;javascript:var i = document.querySelectorAll(&amp;quot;button[data-control-name=withdraw_single]&amp;quot;).length-1; var interval = setInterval(function() {if (location.search.match(/page=/)) {document.querySelectorAll(&amp;quot;button[data-control-name=withdraw_single]&amp;quot;)[i].click();i = ((i&lt;1||i&amp;gt;document.querySelectorAll(&amp;quot;button[data-control-name=withdraw_single]&amp;quot;).length-1)?document.querySelectorAll(&amp;quot;button[data-control-name=withdraw_single]&amp;quot;).length-1:i-1);document.querySelector(&amp;quot;.artdeco-button--primary&amp;quot;).click();} else {clearInterval(interval)}},1000);&quot;&gt;Withdraw&lt;a href=&quot;javascript:var i = document.querySelectorAll(&amp;quot;button[data-control-name=withdraw_single]&amp;quot;).length-1; var interval = setInterval(function() {if (location.search.match(/page=/)) {document.querySelectorAll(&amp;quot;button[data-control-name=withdraw_single]&amp;quot;)[i].click();i = ((i&lt;1||i&amp;gt;document.querySelectorAll(&amp;quot;button[data-control-name=withdraw_single]&amp;quot;).length-1)?document.querySelectorAll(&amp;quot;button[data-control-name=withdraw_single]&amp;quot;).length-1:i-1);document.querySelector(&amp;quot;.artdeco-button--primary&amp;quot;).click();} else {clearInterval(interval)}},1000);&quot;&gt;P&lt;/a&gt;endingLinkedIn&lt;/a&gt; and select add to bookmarks on most Chrome versions that don&amp;#8217;t work.&lt;/p&gt;

&lt;p&gt;Then go to the page from what you need to clear like &lt;a href=&quot;https://www.linkedin.com/mynetwork/invitation-manager/sent/?invitationType=CONNECTION&amp;amp;page=9&quot;&gt;https://www.linkedin.com/mynetwork/invitation-manager/sent/?invitationType=CONNECTION&amp;amp;page=9&lt;/a&gt;, run that bookmark, enjoy!&lt;/p&gt;
</description></item><item><title>Set Busy status from additional Google calendars</title><link>https://lunaxod.com/set-busy-status-from-additional-google-calendars/</link><guid>https://lunaxod.com/set-busy-status-from-additional-google-calendars/</guid><pubDate>Tue, 27 Oct 2020 00:00:00 +0000</pubDate><description>&lt;p&gt;Today I once again faced the fact that a meeting in one calendar was not displayed as a busy time in another calendar; thus, I had 2 invites for the same time in 2 calendars. I also have a personal calendar, which I also use, and it&amp;#8217;s not very cool when someone makes an invite for the time when you have to visit the dentist.&lt;/p&gt;

&lt;p&gt;I tried to find a normal solution, but I couldn&amp;#8217;t find anything, so I made a simple doxy with an app script: http://bit.ly/BusyCalendar&lt;/p&gt;

&lt;p&gt;Instructions:&lt;br&gt;All other calendars must be shared on the main calendar, to which you want to add occupied slots, and you will actually need to execute the script under the user who will add all the calendars to the calendar.&lt;/p&gt;

&lt;ol&gt;&lt;li&gt;Copy the spreadsheet to your account and rename it, the way you&amp;#8217;ll find it later.&lt;/li&gt;&lt;li&gt;In column &amp;#8220;A&amp;#8221; add all the calendars you need to copy the &amp;#8220;Busy&amp;#8221; status.&lt;/li&gt;&lt;li&gt;In column &amp;#8220;B&amp;#8221; add the calendars, where to add the &amp;#8220;Busy&amp;#8221; slots.&lt;/li&gt;&lt;li&gt;In column &amp;#8220;C&amp;#8221; we set how many days ahead to review.&lt;/li&gt;&lt;li&gt;Press the button “Test run” &amp;#8211; accept the permissions, because this is a simple script, then it will still swear, which is unsafe.&lt;/li&gt;&lt;li&gt;Check the calendar. The events should have “shadows.”&lt;/li&gt;&lt;li&gt;Press the “Create trigger” button &amp;#8211; the script will be executed every hour.&lt;/li&gt;&lt;/ol&gt;

&lt;figure&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;433&quot; height=&quot;233&quot; src=&quot;/wp-content/uploads/2020/10/1603735313948.png&quot; alt=&quot;&quot; srcset=&quot;/wp-content/uploads/2020/10/1603735313948.png 433w, /wp-content/uploads/2020/10/1603735313948-300x161.png 300w&quot; sizes=&quot;auto, (max-width: 433px) 100vw, 433px&quot; /&gt;&lt;/figure&gt;

&lt;p&gt;What&amp;#8217;s under the hood: the script goes through the calendars, checks up all events, except those with “Busy from” in the name or declined. It creates an “uninformative” event in the required calendars, just so that it is reflected that the user is busy at this time. Accordingly, if the calendar is shared, then a refusal will automatically come, or it will automatically be reflected for the shared calendar.&lt;/p&gt;

&lt;p&gt;The trigger checks the future once an hour and creates it for all meetings that do not yet have a corresponding “shadow.”&lt;/p&gt;
</description></item><item><title>Measurement protocol V2 Google analytics Apps+Web</title><link>https://lunaxod.com/measurement-protocol-v2-google-analytics-appsweb/</link><guid>https://lunaxod.com/measurement-protocol-v2-google-analytics-appsweb/</guid><pubDate>Fri, 26 Jun 2020 00:00:00 +0000</pubDate><description>&lt;p&gt;In &lt;a href=&quot;https://ringostat.com&quot;&gt;Ringostat&lt;/a&gt;, historically, we have integration with Google Analytics from the beginning. That is a requirement.&lt;/p&gt;

&lt;p&gt;As call tracking, we need to post data about phone calls to sessions of clients, so they will count as events, decrease bounce, and can be used as goals.&lt;/p&gt;

&lt;p&gt;With Google Universal Analytics and release of measurement protocol, this integration became much better, and the first call we registered through measurement protocol on December 29th, 2012, it was in closed beta for that moment. As I know, we were the first call tracking service to do that, but I made a big marketing mistake not to use this in communication.&lt;/p&gt;

&lt;p&gt;But time moves forward, and Google Analytics has a new stage in beta Apps+Web, and we got a client that uses that type of account. And we discovered that our integration doesn&amp;#8217;t work because of new collect endpoint and entirely different way events work there.&lt;/p&gt;

&lt;p&gt;But there is still so low amount of information the only useful article I found from &lt;a href=&quot;https://www.thyngster.com/app-web-google-analytics-measurement-protocol-version-2&quot;&gt;David Vallejo&lt;/a&gt;, but it&amp;#8217;s not currently cover what mandatory fields are and how to get their values.&lt;/p&gt;

&lt;p&gt;So in this article, I&amp;#8217;ll share a summary of mine tries and fails. Which fields mandatory and how to fill them.&lt;/p&gt;

&lt;p&gt;Same as previously, we need to send data to collect endpoint, but now it has another URL. Also changed the protocol version, now it&amp;#8217;s 2. And the way how tracking ID now looks like and now it&amp;#8217;s not tracking ID but stream ID, and you can have different streams for one property.&lt;/p&gt;

&lt;p&gt;Still, you need client ID to post data to the same client, but to get it now, you need to get &amp;#8216;vid&amp;#8217; property of &amp;#8216;gaGlobal&amp;#8217; object. Just call &amp;#8216;gaGlobal.vid&amp;#8217; in javascript instead ga.getAll()[0].get(&amp;#8216;clientId&amp;#8217;) as you could do previously. There is a lot of other differences that I&amp;#8217;ll not cover in this article. But the main difference for me is new params:&lt;/p&gt;

&lt;ol&gt;&lt;li&gt;Session ID, for session ID, used UNIX timestamp of session start and stored in the new cookie that has a name like _ga_XXXXXXXXX where XXXXXXXXX is stream ID without G-.&lt;/li&gt;&lt;li&gt;Session count, number of sessions for this clientId. Weird thing, but it looks like this is required, but it also stored in the cookie.&lt;/li&gt;&lt;/ol&gt;

&lt;p&gt;To parse those variables from the cookie in one line I modified suggested snippets of code to:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;document.cookie.match(&#x27;(^|;)\\s*_ga_XXXXXXXXX\\s*=\\s*(&amp;#91;^;]+)&#x27;).pop().split(&#x27;.&#x27;)&amp;#91;2]&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Just fix XXXXXXXXX with yours stream ID.&lt;/p&gt;

&lt;p&gt;Long story short, we need to build the request with the parameters that I found as required and still send it to https://www.google-analytics.com/g/collect. I found strange information that I have to use only POST request with separation some parameters in URL and other in payload, but GET works fine also. So the params are&lt;/p&gt;

&lt;figure&gt;&lt;table&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Parameter&lt;/td&gt;&lt;td&gt;meaning&lt;/td&gt;&lt;td&gt;What should we use here&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;v&lt;/td&gt;&lt;td&gt;Protocol Version&lt;/td&gt;&lt;td&gt;2&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;tid&lt;/td&gt;&lt;td&gt;Stream ID&lt;/td&gt;&lt;td&gt;G-XXXXXXXXX (find in account or your code)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;cid&lt;/td&gt;&lt;td&gt;Client ID&lt;/td&gt;&lt;td&gt;javascript: gaGlobal.vid&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;sid&lt;/td&gt;&lt;td&gt;Session ID&lt;/td&gt;&lt;td&gt;javascript: document.cookie.match(&amp;#8216;(^|;)\\s*_ga_XXXXXXXXX\\s*=\\s*([^;]+)&amp;#8217;).pop().split(&amp;#8216;.&amp;#8217;)[2]&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;sct&lt;/td&gt;&lt;td&gt;Session Count&lt;/td&gt;&lt;td&gt;javascript: document.cookie.match(&amp;#8216;(^|;)\\s*_ga_XXXXXXXXX\\s*=\\s*([^;]+)&amp;#8217;).pop().split(&amp;#8216;.&amp;#8217;)[3]&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;dl&lt;/td&gt;&lt;td&gt;Document location&lt;/td&gt;&lt;td&gt;javascript: document.location&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;en&lt;/td&gt;&lt;td&gt;Event name&lt;/td&gt;&lt;td&gt;string, how you want to name your event ex. &amp;#8216;call&amp;#8217;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/figure&gt;

&lt;p&gt;Also, it&amp;#8217;s good to add _dbg=1 for debugging to see data in realtime at DebugView (extremely cool feature!).&lt;/p&gt;

&lt;p&gt;After collection such params you can build URL that would look like:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;https:&amp;#47;&amp;#47;www.google-analytics.com/g/collect?v=2&amp;amp;_dbg=1&amp;amp;tid=G-EYPV5EM8S1&amp;amp;cid=1487972805.1593070957&amp;amp;dl=https%3A%2F%2Flunaxod.com&amp;amp;sid=1593070957&amp;amp;sct=1&amp;amp;en=call&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Call it, and in 10 seconds, see the event in DebugView if you have done everything right. After that, you can improve this call with additional event params and user params.&lt;/p&gt;
</description></item><item><title>Trends for eCommerce according to COVID-19</title><link>https://lunaxod.com/trends-for-ecommerce-according-to-covid-19/</link><guid>https://lunaxod.com/trends-for-ecommerce-according-to-covid-19/</guid><pubDate>Wed, 11 Mar 2020 00:00:00 +0000</pubDate><description>&lt;p&gt;I tried not to post this, but I feel that I have to.&lt;/p&gt;

&lt;p&gt;Panic, according to coronavirus (COVID-19) and the current international economic situation, will lead to changes. I&amp;#8217;m not a prominent economist neither virologist.&lt;/p&gt;

&lt;figure&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1024&quot; height=&quot;352&quot; src=&quot;/wp-content/uploads/2020/03/28dayslater-1024x352.jpg&quot; alt=&quot;&quot; srcset=&quot;/wp-content/uploads/2020/03/28dayslater-1024x352.jpg 1024w, /wp-content/uploads/2020/03/28dayslater-300x103.jpg 300w, /wp-content/uploads/2020/03/28dayslater-768x264.jpg 768w, /wp-content/uploads/2020/03/28dayslater.jpg 1500w&quot; sizes=&quot;auto, (max-width: 1024px) 100vw, 1024px&quot; /&gt;&lt;/figure&gt;

&lt;p&gt;But something I understand and as I see the possibilities. In the situation with panic levels increase and more attempts used to stop spread the virus are implemented: cancelation of significant events, quarantines, etc. will lead to a new wave of people to adopt eCommerce and delivery services.&lt;/p&gt;

&lt;p&gt;Yesterday I was at the shopping mall and had no problem parking a car near the entrance while usually on the same weekday and time I have to park 100 meters away, and this is the city, where no one has this virus.&lt;/p&gt;

&lt;p&gt;But people still need goods, and they still have money, so where will they go in a while? And how will they differ from those who are already there?&lt;/p&gt;

&lt;p&gt;As an analyst, I want to verify the hypothesis and only after that make decisions, but at the moment when everything could be confirmed, those who took risks will be a king others will loose.&lt;/p&gt;

&lt;h2&gt;So what are my hypotheses?&lt;/h2&gt;

&lt;ol&gt;&lt;li&gt;We will see the growth in eCommerce with delivery.&lt;/li&gt;&lt;li&gt;This new wave of customers would differ from current customers:&lt;ul&gt;&lt;li&gt;They will prefer their usual way of communication: telephone, messengers.&lt;/li&gt;&lt;li&gt;They will use dramatically different queries in search engines.&lt;/li&gt;&lt;li&gt;They will act on the website in another way.&lt;/li&gt;&lt;/ul&gt;&lt;/li&gt;&lt;li&gt;Brands will have to move advertising budgets from offline needs to reattract customers from offline to online.&lt;/li&gt;&lt;li&gt;The previous point will lead to an increase in the competition, so the bids will grow as required budgets.&lt;/li&gt;&lt;/ol&gt;

&lt;h2&gt;What a business can do to win in that situation?&lt;/h2&gt;

&lt;ol&gt;&lt;li&gt;Your customers are the best way to grow! Work more with current customers:&lt;ul&gt;&lt;li&gt;Useful personalized emails still work. Create even more precise, accurate email and get profit.&lt;/li&gt;&lt;li&gt;Little manipulation works focus on the relief of customers&amp;#8217; pain but be creative. They are full of fears. Don&amp;#8217;t make them hate you.&lt;/li&gt;&lt;li&gt;Your customers should attract new customers. Nothing new, but in the case of CAC (customer acquisition cost) growth, this is an excellent way to attract new customers.&lt;/li&gt;&lt;/ul&gt;&lt;/li&gt;&lt;li&gt;Be proactive. As we expect a wave of new customers that are uncommon with eCommerce and came here in need, we have to make their experience comfortable. The idea is that if they still preferred offline, they need to communicate and like the attention they get in offline. Try to implement:&lt;ul&gt;&lt;li&gt;Online chat with proactive messages engaging communication.&lt;/li&gt;&lt;li&gt;Callback widget services with activity tracking inviting to leave a phone number and get a call in seconds.&lt;/li&gt;&lt;li&gt;Track the activity of already known users and follow up them with a strictly personalized email or even phone call from a representative with an offer that the user was interested in.&lt;/li&gt;&lt;li&gt;Remarketing campaigns for those new customers also looks like magic.&lt;/li&gt;&lt;/ul&gt;&lt;/li&gt;&lt;li&gt;Finally, implement CRM and business flows according to it.&lt;/li&gt;&lt;li&gt;Start experimenting even more with online ads. Expecting new way of customers that will act differently, you should try to experiment with creatives, keywords, approaches. Some hypotheses that didn&amp;#8217;t work previously can bring great results now.&lt;ul&gt;&lt;li&gt;Choose hypotheses ➡ Test them ➡ Measure results ➡ Scale that works ➡ Validate ➡ Scale more.&lt;/li&gt;&lt;li&gt;Measure all types of conversions. Expect growth in phone calls, messengers, online chats. Conversions made by such channels will differ from those that occur online. Different keywords, different intents, creatives, average check, time to finish the conversion, everything.&lt;/li&gt;&lt;li&gt;Implement end-to-end analytics to start tracking revenue (better profits) that occurred by each exact ad and make improvements on each stage.&lt;/li&gt;&lt;/ul&gt;&lt;/li&gt;&lt;li&gt;Implement modern business telephony that will not only help to answer the call but much more:&lt;ul&gt;&lt;li&gt;Provide call recording for future analysis to make business flow improvements, collect feedback, and use it in marketing and other business needs.&lt;/li&gt;&lt;li&gt;Know better your customers get insights on a caller from CRM and analytics system in real-time to provide better service presuming customer needs.&lt;/li&gt;&lt;li&gt;Transfer callers to highly specialized representatives.&lt;/li&gt;&lt;/ul&gt;&lt;/li&gt;&lt;li&gt;Implement instruments that help to work with messaging apps in a pro way.&lt;/li&gt;&lt;/ol&gt;

&lt;h2&gt;Never give up!&lt;/h2&gt;

&lt;p&gt;Crysis is a vast possibility to grow for those who deal with it!&lt;/p&gt;
</description></item><item><title>Who is Lunaxod?</title><link>https://lunaxod.com/who-is-lunaxod/</link><guid>https://lunaxod.com/who-is-lunaxod/</guid><pubDate>Tue, 24 Feb 2015 00:00:00 +0000</pubDate><description>&lt;p&gt;Finally decided to use this domain name that I bought more than one year ago.&lt;/p&gt;
&lt;p&gt;I&amp;#8217;m CEO of Intelligent Call Tracking service &lt;a href=&quot;https://ringostat.com&quot; title=&quot;Ringostat&quot;&gt;Ringostat&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I&amp;#8217;m not fan of blogging but hope time to time I will find some time to make some notes about my work and practice cases here.&lt;/p&gt;
&lt;p&gt;Also I plan post mostly in my not very good english for practice but sometimes it would be russian posts.&lt;/p&gt;

&lt;p&gt;Shortly about myself:&lt;/p&gt;
&lt;p&gt;I started my career in internet marketing with small e-commerce project with portable solar batteries in 2005. At that time I started to learn something about SEO, PPC advertising and analytics for myself. Tried myself in selling links and SEO copywriting. Created some MFA web-sites.&lt;/p&gt;
&lt;p&gt;In 2010 I started my career in &lt;a href=&quot;http://netpeak.net&quot;&gt;Netpeak&lt;/a&gt; internet marketing agency as SEO specialist, later was SEO team-lead, Head of SEO department, started PPA department, launched landing page creation service.&lt;/p&gt;
&lt;p&gt;But at some moment I realized that my true passinon is WEB-analytics. I started deeply learning of Google Analytics. Not only the basics but deeper analytics, how it works, what happens when script of GA work, how it saves data, what cookies does it use, how it sends data to server, how we can modify it to get better insights, do some changes and implement split tests.&lt;/p&gt;
&lt;p&gt;I had cases when changing LP made double increase in conversion rate. Little changes on this new page gave us 30% more.&lt;/p&gt;
&lt;p&gt;But deeper I learned analytics more and more I realized that we missing something and that something were phone calls. Phone calls are great conversions but GA doesn&amp;#8217;t see them, even visit with one pageview that finished as phone call is bounce while it&amp;#8217;s great conversion. So we made first prototype of Ringostat it was only classic call tracking, had lot of issues but still we get much more insights. I understand that it is my way and we need to do more we made dynamic call tracking. When Google made Universal Analytics with Measurement Protocol as soon as I get invite to beta we made integration. I felt almos orgasm seeing phone calls linked to visitor of the web-site in Alanytics.&lt;/p&gt;
&lt;p&gt;First phone call in test account we send to Universal Analytics 29 December 2012 I&amp;#8217;m not sure but looks like we were first with such integration but I was to shy and stupid not to shout about this wherever it was possible.&lt;/p&gt;
&lt;p&gt;From that time lot of things changed. Ringostat now is Great product I&amp;#8217;m CEO of it, we have lot of plans what to implement. Roadmap for more than two years ahead.&lt;/p&gt;
&lt;p&gt;So who am I?&lt;/p&gt;
&lt;p&gt;My name is Oleksandr, I&amp;#8217;m from Odessa, Ukraine. WEB-analyst, certified in Google Analytics and Google AdWords, know a lot about Google Tag Manager, SEO, PPC, something about other Internet Marketing services, CEO of Ringostat Intelligent Call Tracking service, speaker at the conferences.&lt;/p&gt;
&lt;p&gt;What is this blog about and what for?&lt;/p&gt;
&lt;p&gt;It&amp;#8217;s not the official Ringostat blog, it&amp;#8217;s place where I will left notes about my work, my interests, WEB-analytics, something I learned, if you have interesting questions for me I can try to answer so don&amp;#8217;t hesistate to ask.&lt;/p&gt;
</description></item></channel></rss>
