skip to Main Content

I have a WordPress website where I use LearnDash with multiple courses, lessons and quizzes.

I? am looking to set up GTM in order to better track what my visitors are doing. There is a container in place and from the console, I am able to use the datalayer.push() to push custom events to my GTM.

I would like to pass certain data to my GTM for when specific hooks are fired, for example, when someone completes a course or a lesson. Luckily there are action hooks for this provided by?

So far, I have an implementation of SSE in place which sends the data from database to frontend but it is unreliable. What other approaches can I use for this?

Currently saving the Data to database when a hook is triggered, then using SSE (server sent events) to push this data to frontend. However, due to the persistent connection, this causes my CPU to crash so its not so reliable, especially when there are lots of users.

This method does work, as a new event is created, saved to the database, then sent to the frontend from where I can then use datalayer.push to add this to GTM, but again, it is unreliable and crashes the website from time to time.

2

Answers


  1. Chosen as BEST ANSWER
      public function start()
      {
    
        ignore_user_abort(true);
        set_time_limit(0);
    
        header('Content-Type: text/event-stream; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate, #proxy-revalidate, max-age=0');
        header('Last-Modified: ' . time());
        header('Access-Control-Allow-Origin: *');
        header('X-Accel-Buffering: no');
    
        sleep(3);
    
        if ($this->lock->isLocked()) {
    
          $this->log->error('Duplicate SSE session', [
            'user_id' => get_current_user_id(),
          ]);
    
          return new WP_Error(
            'duplicate_sse_connection',
            'Only one active SSE connection allowed',
            ['status' => 429]
          );
        }
    
        $this->lock->lock();
    
        register_shutdown_function(
          fn () => $this->lock->unlock()
        );
    
        while (true) {
    
            if(connection_aborted()) {
              break;
            }
    
            wp_cache_flush();
    
            $events = $this->events->getAll();
    
            if (is_wp_error($events)) break;
    
            /**
             * Sending more than 1 SSE message at a time causes weird and unexpected behaviour
             */
            if (!empty($events) && isset($events[0])) {
    
              $event = $events[0];
    
              $attempt = $event->attempt();
    
              if (is_wp_error($attempt)) continue;
    
              $data = ['id' => $event->id, 'payload' => $event->data];
    
              $json = json_encode($data);
    
              $id = $event->id;
    
              $msg = $this->makeMessage($json, $id);
    
              $this->sendMessage($msg);
    
            } else {
    
              $this->sendMessage(":keep-alivenn");
    
              sleep(1);
    
            }
    
            // if (empty($events)) $this->sendMessage(":keep-alivenn");
    
            // $this->log->info('Loop is alive with heartbeat 2');
    
            sleep(1);
    
            // we deliberately use locks with short expiry  to account for
            // fatal crashes, timeouts, unexpected events, etc.
    
            // refresh lock if it has expired
            if ($this->lock->hasExpired()) {
              $this->lock->lock();
            }
              
        } 
      }
    
      /**
       * Make message compatible with Server Sent Events format
       */
      protected function makeMessage(
        string $data,
        ?string $id = null
      ): string {
    
        $msg = '';
    
        $msg .= sprintf('data: %s%s', $data, "n");
    
        if (isset($id)) $msg .= sprintf('id: %s%s', $id, "n");
    
        $msg .= sprintf('retry: %s%s', 6000, "n");
    
        $msg .= sprintf('%s%s', "n", "n");
    
        return $msg;
      }
    
      /**
       * Send Server Sent Events to the frontend
       * @link https://javascript.info/server-sent-events
       */
      protected function sendMessage(string $msg): void
      {
        echo $msg;
    
        if (ob_get_contents()) ob_end_clean();
    
        flush();
    
        sleep(1);
      }
    

    Yeah I was thinking there is some sort of mistake in the code, however I have been over this for many days and its starting to drive me insane. This is the PHP code that I use for the SSE events.

    This is the frontend code

    import { Schemas, Types } from '../Schemas';
    
    declare global {
      interface Window {
        gtm?: Types['GTM'];
        dataLayer?: {
          push: (event: Types['Event']) => void;
        };
      }
    }
    
    let sse: EventSource | null = null;
    let retryInProgress: boolean = false;
    
    document.addEventListener('DOMContentLoaded', () => {
      if (!document.body.classList.contains('logged-in')) {
        return console.warn('sse disabled, no active user session');
      }
      if (!window.gtm) {
        return console.error('Missing window.gtm');
      }
      const gtm = Schemas.GTM.safeParse(window.gtm);
      if (gtm.success) {
        startSSE(gtm.data);
        window.addEventListener('beforeunload', () => {
          unlock(gtm.data);
        });
      }
    });
    
    
    function startSSE(gtm: Types['GTM']): void {
    
      if(sse !== null) {
        console.log('SSE connection is already active');
        return;
      }
    
      const url = new URL(gtm.events);
      // without this, you will get 403 error
      url.searchParams.append('_wpnonce', gtm.nonce);
    
      // sse = server-sent events
      sse = new EventSource(url.href);
    
      sse.addEventListener('open', (e) => {
        console.log('sse has started');
      });
    
      sse.addEventListener('error', (e) => {
        if (sse.readyState === EventSource.CLOSED) {
          console.log('sse connection closed manually');
          gtm.retry++;
          window.gtm = gtm;
          // increase timeout with each retry to avoid spamming
          setTimeout(() => startSSE(gtm), gtm.retry * gtm.wait);
    
          // if (!retryInProgress) {
          //   retryInProgress = true;
    
          //   gtm.retry++;
          //   window.gtm = gtm;
    
          //   setTimeout(() => {
          //     retryInProgress = false;
          //     startSSE(gtm);
          //   }, gtm.retry * gtm.wait);
          // }
    
        }
    
        if (sse.readyState !== EventSource.CLOSED) console.error(e);
      });
    
      sse.addEventListener('message', (e) => {
        // https://web.dev/eventsource-basics/#a-word-on-security
        if (e.origin !== window.location.origin) return;
    
        let json = null;
    
        try {
          json = JSON.parse(e.data);
        } catch (error) {
          console.log('failed to parse json');
          console.log(error);
          return;
        }
    
        if (!json) return console.log('unexpected state');
    
        const sse = Schemas.Message(Schemas.Event);
    
        const msg = sse.safeParse(json);
    
        if (!msg.success) {
          console.error('failed to parse SSE message');
          console.error(e.data);
          console.error(msg.error);
          return;
        }
    
        const {
          data: { id, payload: event },
        } = msg;
    
        const data = new FormData();
    
        data.set('id', id);
    
        const init: RequestInit = {
          method: 'POST',
          body: data,
        };
    
        fetch(url, init).then((response) => {
          if (response.ok && window.dataLayer) {
            window.dataLayer.push(event);
          }
        });
      });
    }
    
    function unlock(gtm: Types['GTM']): void {
      const url = new URL(gtm.unlock);
      url.searchParams.append('_wpnonce', gtm.nonce);
      const data = new FormData();
      data.set('_wpnonce', gtm.nonce);
      fetch(url, {
        method: 'POST',
      });
      console.log('closing sse connection');
      // we do not need to wait for this as sse lock has timeout
    }
    
    export {};
    

    I have taken extra caution to prevent multiple SSE connections being open by introducing locks into the PHP. The concurrency lock is to prevent multiple connections per users in case they open multiple tabs.

    Perhaps there is something wrong in my code that is causing it to crash? At this point, I am not sure as I feel I have tried everything. If anyone knows, then I would appreciate some guidance.


  2. First of all, your crashing issues are unrelated. You probably made some architectural mistakes that increase complexity dramatically at some step where no complexity is required really.

    The way you described it is pretty much the only way to send events to front-end GTM. There’s a server-side GTM that doesn’t require front-end as a middleware, but most likely you don’t need that level of complexity.

    If the only thing you need to track is people finishing courses, you can just track something like the course completion page maybe parsing DOM from the GTM to detect it if there’s no clear url pattern. Doing this kind of truly trivial tracking through backend sounds like a huge mistake.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search